@operato/twin-kernel 0.7.0 → 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.
@@ -1169,6 +1169,60 @@ export interface Attention {
1169
1169
  args?: unknown;
1170
1170
  };
1171
1171
  }
1172
+ /** 계량 지점의 마지막 관측 — 값이 없으면 **모르는 것이다**(0 이 아니다). */
1173
+ export interface MeterPointState {
1174
+ id: string;
1175
+ kW?: number;
1176
+ /** 누적 전력량(원천이 준 적산값) — 우리가 적분한 값이 아니다. */
1177
+ kWh?: number;
1178
+ powerFactor?: number;
1179
+ /** 마지막 표본의 시각 — 계측이 끊긴 것을 소비처가 알 수 있게. */
1180
+ atMs?: number;
1181
+ /** 이 구간에 받은 표본 수 — 「못 쟀다」와 「0이었다」를 구별한다. */
1182
+ samplesInWindow: number;
1183
+ }
1184
+ export interface DemandWindowState {
1185
+ startMs: number;
1186
+ endMs: number;
1187
+ /** 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다). */
1188
+ maxKW?: number;
1189
+ /** 표본 평균 부하 — 요금 산정의 평균 수요에 대응한다. */
1190
+ meanKW?: number;
1191
+ /** **kW 를 실은** 표본 수 — 부하 판정의 근거 수다(받은 표본 전체가 아니다). */
1192
+ samples: number;
1193
+ /** 계약전력 대비 — 계약을 모르면 `undefined`(짐작하지 않는다). */
1194
+ contractKW?: number;
1195
+ overContract?: boolean;
1196
+ }
1197
+ export interface EnergyState {
1198
+ points: MeterPointState[];
1199
+ /** 지금 열려 있는 구간 — 아직 마감되지 않았다(예측은 `projectedKW`). */
1200
+ open?: DemandWindowState & {
1201
+ projectedKW?: number;
1202
+ projectionBasis?: 'mean-so-far';
1203
+ };
1204
+ /**
1205
+ * 마감된 구간들 — **최근 것만 들고 있다**(상태 크기가 시간에 비례하지 않게).
1206
+ * 자른 사실을 `closedTotal` 로 함께 낸다 — 조용히 자르지 않는다.
1207
+ */
1208
+ closed: DemandWindowState[];
1209
+ closedTotal: number;
1210
+ /** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
1211
+ peakSince?: {
1212
+ kW: number;
1213
+ windowStartMs: number;
1214
+ };
1215
+ /** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
1216
+ contractKW?: number;
1217
+ /**
1218
+ * 이 커널이 받은 **물류 흐름 요청** — 에너지에는 없는 것들이다(도착·오더·배정·작업 완료).
1219
+ * 비어 있지 않으면 배선 오류다: EMS 트윈에 물류 명령이 오고 있다. 조용히 넘기지 않는다.
1220
+ */
1221
+ flowRequests?: {
1222
+ hook: string;
1223
+ count: number;
1224
+ }[];
1225
+ }
1172
1226
  /**
1173
1227
  * 조치방향 — code=안정 조치 키(언어 중립). command 있으면 원클릭 실행, 없으면 권고.
1174
1228
  * 라벨·힌트(사람 언어)는 표현계층이 code 로 렌더(커널은 문장 미보유). command 보유 조치는 code=command 문자열,
@@ -1217,6 +1271,13 @@ export interface StateSnapshot {
1217
1271
  * 왕복시켜야 재기동·재계산에서 확인 상태가 유지된다.
1218
1272
  */
1219
1273
  acked?: string[];
1274
+ /**
1275
+ * 에너지 — **에너지 트윈만 채운다**(계량 지점·수요 구간·피크). 다른 종류에서는 없다.
1276
+ *
1277
+ * 없는 것과 빈 것을 구별한다: 필드가 아예 없으면 그 트윈은 에너지를 재지 않는 것이고,
1278
+ * `points: []` 는 「아직 표본이 없다」다.
1279
+ */
1280
+ energy?: EnergyState;
1220
1281
  }
1221
1282
  export interface Command<T = unknown> {
1222
1283
  commandId: string;
@@ -87,8 +87,12 @@ export interface TwinAxisInfo {
87
87
  * board 안에서의 자리. 최상위면 `axis` 와 같고, 중첩이면 경로다
88
88
  * (`productionSpec.definition.recipes`).
89
89
  *
90
- * **`source: 'document'` 때만 있다.** 상태·저널 축에는 board 안의 자리가 없다 문자열로
91
- * 두면 소비처가 board 루트를 읽고 통째로 잘못된 답을 만든다.
90
+ * **`source` 가리키는 자료 안의 경로다** 예전에는 「`document` 때만 있다」고 못 박았는데,
91
+ * 상태 축도 중첩될 있다는 것이 에너지에서 드러났다(수요 구간은 `state.energy.closed` 에 산다).
92
+ * 그때 축 이름을 상태의 최상위 키로 맞추려면 같은 배열을 두 자리에 실어야 했다(방송이 그만큼 커진다).
93
+ *
94
+ * 규칙은 하나다: **경로가 있으면 그 경로로 읽고, 없으면 축 이름으로 읽는다.** 빈 문자열로 두지
95
+ * 않는다 — 소비처가 자료의 뿌리를 읽고 통째로 잘못된 답을 만든다.
92
96
  *
93
97
  * **레시피·라우트는 저장상 `productionSpec` 안에 있지만 개념으로는 1급**이다 — 사람은
94
98
  * "레시피" 를 찾지 "생산 정의 안의 레시피" 를 찾지 않는다. 저장 위치가 개념을 가두면
@@ -96,7 +96,27 @@ export const TWIN_AXES = [
96
96
  { axis: 'orders', label: 'twin.axis.orders', kind: 'instance', source: 'state', historical: true,
97
97
  standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' }, systems: LOGISTICS },
98
98
  { axis: 'tasks', label: 'twin.axis.tasks', kind: 'instance', source: 'state', historical: true,
99
- standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS }
99
+ standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS },
100
+ /*
101
+ * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
102
+ *
103
+ * 에너지 트윈의 개체 대부분은 **이미 있는 축**이 답한다: 전기 구간은 `locations`, 계량기·차단기·
104
+ * 태양광·축전지·감축 부하는 `equipment` 다(카탈로그가 그 타입들을 그 역할로 선언한다). 그것들을
105
+ * 새 축으로 다시 세우면 같은 것이 두 곳에서 세어진다 — 개념 지도가 계량기를 두 번 보여 준다.
106
+ *
107
+ * 정말로 새로운 것은 **수요 구간**이다: 자원이 아니고, 선언이 아니고, 15분마다 닫히는 **사실**이다.
108
+ * 그것이 요금의 알갱이이고 피크의 근거다(피크는 마감된 구간의 최대이므로 파생이다 — 축이 아니다).
109
+ *
110
+ * ── 표준 칸을 비운다 ──────────────────────────────────────────────────────
111
+ * 15분 수요 구간은 **요금 제도의 알갱이**다(계약·TOU). ISO 50001 은 경영 체계를, IEC 61850 은 설비
112
+ * 데이터 모델을 말하고, 둘 다 이 구간을 정의하지 않는다. 가까운 이름을 적으면 적합성 표가 거짓을
113
+ * 말하므로 비워 둔다 — 「표준에 자리가 없으면 빈 객체」라는 이 선언의 규율 그대로다.
114
+ *
115
+ * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
116
+ * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
117
+ */
118
+ { axis: 'demandWindows', path: 'energy.closed', label: 'twin.axis.demandWindows', kind: 'instance',
119
+ source: 'state', historical: true, standardClass: {}, systems: ['ems'] }
100
120
  ];
101
121
  /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
102
122
  export const TWIN_RELATIONS = [
@@ -1,64 +1,10 @@
1
1
  import { FlowEngine } from './flow-engine.ts';
2
2
  import { type AllocationPolicy } from './allocation-policy.ts';
3
- import { type CanonicalEnvelope, type StateSnapshot } from './contract.ts';
3
+ import { type CanonicalEnvelope, type DemandWindowState, type StateSnapshot } from './contract.ts';
4
4
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
5
5
  export declare const DEMAND_WINDOW_MS: number;
6
6
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
7
7
  export declare const demandWindowStart: (atMs: number, windowMs?: number) => number;
8
- /** 계량 지점의 마지막 관측 — 값이 없으면 **모르는 것이다**(0 이 아니다). */
9
- export interface MeterPointState {
10
- id: string;
11
- kW?: number;
12
- /** 누적 전력량(원천이 준 적산값) — 우리가 적분한 값이 아니다. */
13
- kWh?: number;
14
- powerFactor?: number;
15
- /** 마지막 표본의 시각 — 계측이 끊긴 것을 소비처가 알 수 있게. */
16
- atMs?: number;
17
- /** 이 구간에 받은 표본 수 — 「못 쟀다」와 「0이었다」를 구별한다. */
18
- samplesInWindow: number;
19
- }
20
- export interface DemandWindowState {
21
- startMs: number;
22
- endMs: number;
23
- /** 그 구간의 최대 순간부하 — 표본이 없으면 `undefined`(0 이 아니다). */
24
- maxKW?: number;
25
- /** 표본 평균 부하 — 요금 산정의 평균 수요에 대응한다. */
26
- meanKW?: number;
27
- /** **kW 를 실은** 표본 수 — 부하 판정의 근거 수다(받은 표본 전체가 아니다). */
28
- samples: number;
29
- /** 계약전력 대비 — 계약을 모르면 `undefined`(짐작하지 않는다). */
30
- contractKW?: number;
31
- overContract?: boolean;
32
- }
33
- export interface EnergyState {
34
- points: MeterPointState[];
35
- /** 지금 열려 있는 구간 — 아직 마감되지 않았다(예측은 `projectedKW`). */
36
- open?: DemandWindowState & {
37
- projectedKW?: number;
38
- projectionBasis?: 'mean-so-far';
39
- };
40
- /**
41
- * 마감된 구간들 — **최근 것만 들고 있다**(상태 크기가 시간에 비례하지 않게).
42
- * 자른 사실을 `closedTotal` 로 함께 낸다 — 조용히 자르지 않는다.
43
- */
44
- closed: DemandWindowState[];
45
- closedTotal: number;
46
- /** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
47
- peakSince?: {
48
- kW: number;
49
- windowStartMs: number;
50
- };
51
- /** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
52
- contractKW?: number;
53
- /**
54
- * 이 커널이 받은 **물류 흐름 요청** — 에너지에는 없는 것들이다(도착·오더·배정·작업 완료).
55
- * 비어 있지 않으면 배선 오류다: EMS 트윈에 물류 명령이 오고 있다. 조용히 넘기지 않는다.
56
- */
57
- flowRequests?: {
58
- hook: string;
59
- count: number;
60
- }[];
61
- }
62
8
  export declare class EmsKernel extends FlowEngine {
63
9
  private points;
64
10
  private open?;
@@ -68,7 +14,23 @@ export declare class EmsKernel extends FlowEngine {
68
14
  /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
69
15
  private suggestedFor?;
70
16
  private windowMs;
71
- constructor(tenantId: string, policy?: AllocationPolicy, windowMs?: number);
17
+ /**
18
+ * ── 세 번째 인자는 **호스트가 정한 자리**다 (2026-08-14 실측으로 고침) ──────
19
+ *
20
+ * 호스트는 모든 커널을 한 모양으로 세운다: `new Kernel(tenantId, undefined, productionSpecOf(model))`.
21
+ * 즉 세 번째 인자는 **도메인 옵션 슬롯**이고 커널마다 뜻이 다르다(야드는 모드, 생산은 명세).
22
+ *
23
+ * 처음에 이 자리를 `windowMs: number` 로 받았다가, 라이브에서 **모든 수요 구간이 깨졌다** —
24
+ * 생산 명세 객체가 창 길이로 들어와 `startMs: null`·`endMs: NaN` 이 됐다. 내 시험은 커널을
25
+ * `new EmsKernel('t')` 로 직접 세웠기 때문에 그것을 잡지 못했다(호스트와 다른 방식으로 세운 것이
26
+ * 그 자체로 결함이었다).
27
+ *
28
+ * 그래서 **쓸 수 있는 것만 읽는다**: 수면 창 길이로 쓰고, 객체면 `windowMs` 를 찾고, 없으면 기본값
29
+ * (15분)이다. 모르는 것을 창 길이로 삼지 않는다.
30
+ */
31
+ constructor(tenantId: string, policy?: AllocationPolicy, opts?: number | {
32
+ windowMs?: number;
33
+ } | unknown);
72
34
  /**
73
35
  * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
74
36
  *
@@ -33,6 +33,15 @@ import { EMS_PROPERTY } from "./ems-profile.js";
33
33
  export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
34
34
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
35
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
+ }
36
45
  /** 상태에 남기는 마감 구간 수 — 하루치(15분 × 96). 그 앞은 저널이 답한다. */
37
46
  const KEEP_CLOSED = 96;
38
47
  export class EmsKernel extends FlowEngine {
@@ -44,9 +53,23 @@ export class EmsKernel extends FlowEngine {
44
53
  /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
45
54
  suggestedFor;
46
55
  windowMs;
47
- constructor(tenantId, policy = firstFitPolicy, windowMs = DEMAND_WINDOW_MS) {
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) {
48
71
  super(tenantId, policy);
49
- this.windowMs = windowMs;
72
+ this.windowMs = windowMsOf(opts);
50
73
  }
51
74
  /**
52
75
  * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
@@ -2,7 +2,7 @@ import type { TwinTypeInfo } from './domain-catalog.ts';
2
2
  /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
3
3
  export declare const EMS_LOCATION_TYPES: readonly ["incoming", "feeder", "submeter-zone"];
4
4
  /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
5
- export declare const EMS_EQUIPMENT_TYPES: readonly ["meter", "breaker", "pv-array", "battery", "curtailable-load"];
5
+ export declare const EMS_EQUIPMENT_TYPES: readonly ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
6
6
  /**
7
7
  * 커널이 **읽는** 자리 속성 — 뜻을 코드 한가운데 숨기지 않는다.
8
8
  *
@@ -1,7 +1,7 @@
1
1
  /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
2
2
  export const EMS_LOCATION_TYPES = ['incoming', 'feeder', 'submeter-zone'];
3
3
  /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
4
- export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', 'curtailable-load'];
4
+ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', 'utility', 'curtailable-load'];
5
5
  /**
6
6
  * 커널이 **읽는** 자리 속성 — 뜻을 코드 한가운데 숨기지 않는다.
7
7
  *
@@ -93,6 +93,28 @@ export const EMS_TYPES = [
93
93
  identity: { scheme: 'kernel:id' },
94
94
  capabilities: ['storing', 'metered', 'operable']
95
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
+ },
96
118
  {
97
119
  key: 'curtailable-load',
98
120
  role: 'equipment',
@@ -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;
@@ -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";
@@ -77,6 +77,7 @@ __export(index_exports, {
77
77
  activeShiftOf: () => activeShiftOf,
78
78
  aggregationEvent: () => aggregationEvent,
79
79
  analyzeCapacity: () => analyzeCapacity,
80
+ attributeEnergy: () => attributeEnergy,
80
81
  axesOfSystem: () => axesOfSystem,
81
82
  axisAppliesTo: () => axisAppliesTo,
82
83
  axisInfo: () => axisInfo,
@@ -94,6 +95,8 @@ __export(index_exports, {
94
95
  documentPath: () => documentPath,
95
96
  dueStatusOf: () => dueStatusOf,
96
97
  effectivityAt: () => effectivityAt,
98
+ energyIntensity: () => energyIntensity,
99
+ energyOfWindows: () => energyOfWindows,
97
100
  fefoPolicy: () => fefoPolicy,
98
101
  firstFitPolicy: () => firstFitPolicy,
99
102
  foldJobResponses: () => foldJobResponses,
@@ -104,6 +107,8 @@ __export(index_exports, {
104
107
  inWorkCalendar: () => inWorkCalendar,
105
108
  inWorkCalendarAt: () => inWorkCalendarAt,
106
109
  ingest: () => ingest,
110
+ ingestEnergyRecords: () => ingestEnergyRecords,
111
+ isEnergyRecord: () => isEnergyRecord,
107
112
  isEquipmentLevel: () => isEquipmentLevel,
108
113
  isoDurationHours: () => isoDurationHours,
109
114
  itemKeyOf: () => itemKeyOf,
@@ -1598,7 +1603,7 @@ var WMS_TYPES = [
1598
1603
 
1599
1604
  // src/ems-profile.ts
1600
1605
  var EMS_LOCATION_TYPES = ["incoming", "feeder", "submeter-zone"];
1601
- var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "curtailable-load"];
1606
+ var EMS_EQUIPMENT_TYPES = ["meter", "breaker", "pv-array", "battery", "utility", "curtailable-load"];
1602
1607
  var EMS_PROPERTY = {
1603
1608
  /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
1604
1609
  contractKW: "contract.kW"
@@ -1684,6 +1689,28 @@ var EMS_TYPES = [
1684
1689
  identity: { scheme: "kernel:id" },
1685
1690
  capabilities: ["storing", "metered", "operable"]
1686
1691
  },
1692
+ {
1693
+ key: "utility",
1694
+ role: "equipment",
1695
+ label: "twin.type.utility",
1696
+ /*
1697
+ * 공통 설비 — 공조·컴프레서·칠러·조명·폐수처리처럼 **어느 공정에도 귀속되지 않는** 소비처.
1698
+ *
1699
+ * ── 왜 따로 있나 (2026-08-14) ────────────────────────────────────────────
1700
+ * 물류·생산 트윈에서 이런 것들은 **설비가 아니다**(공정에 매핑되지 않으므로 자원 축에 없다).
1701
+ * 그런데 에너지에서는 소비의 절반을 차지하고 감축 후보 1순위다 — 담을 자리가 반드시 있어야 한다.
1702
+ *
1703
+ * 처음에는 `curtailable-load` 하나로 받으려 했다. 그런데 그 이름은 **「줄일 수 있다」고 주장**한다:
1704
+ * 폐수처리·방폭 환기·서버실 냉방은 공통이지만 줄일 수 없고, 그것을 감축 가능으로 두면 트윈이
1705
+ * 「이걸 줄이면 됩니다」라는 거짓 제안을 한다. 공통성과 감축 가능성은 **다른 축**이다.
1706
+ *
1707
+ * 표준: IEC 61850 에 「공통 설비」라는 논리 노드는 없다(설비 종류마다 다른 노드다) — 비운다.
1708
+ * ISO 50001 의 SEU 는 이 부류를 가장 많이 가리킨다(유의 에너지 사용처).
1709
+ */
1710
+ standardClass: { iso50001: "SEU", iso55000: "Asset" },
1711
+ identity: { scheme: "kernel:id" },
1712
+ capabilities: ["metered", "operable"]
1713
+ },
1687
1714
  {
1688
1715
  key: "curtailable-load",
1689
1716
  role: "equipment",
@@ -2030,6 +2057,34 @@ var TWIN_AXES = [
2030
2057
  historical: true,
2031
2058
  standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
2032
2059
  systems: LOGISTICS
2060
+ },
2061
+ /*
2062
+ * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
2063
+ *
2064
+ * 에너지 트윈의 개체 대부분은 **이미 있는 축**이 답한다: 전기 구간은 `locations`, 계량기·차단기·
2065
+ * 태양광·축전지·감축 부하는 `equipment` 다(카탈로그가 그 타입들을 그 역할로 선언한다). 그것들을
2066
+ * 새 축으로 다시 세우면 같은 것이 두 곳에서 세어진다 — 개념 지도가 계량기를 두 번 보여 준다.
2067
+ *
2068
+ * 정말로 새로운 것은 **수요 구간**이다: 자원이 아니고, 선언이 아니고, 15분마다 닫히는 **사실**이다.
2069
+ * 그것이 요금의 알갱이이고 피크의 근거다(피크는 마감된 구간의 최대이므로 파생이다 — 축이 아니다).
2070
+ *
2071
+ * ── 표준 칸을 비운다 ──────────────────────────────────────────────────────
2072
+ * 15분 수요 구간은 **요금 제도의 알갱이**다(계약·TOU). ISO 50001 은 경영 체계를, IEC 61850 은 설비
2073
+ * 데이터 모델을 말하고, 둘 다 이 구간을 정의하지 않는다. 가까운 이름을 적으면 적합성 표가 거짓을
2074
+ * 말하므로 비워 둔다 — 「표준에 자리가 없으면 빈 객체」라는 이 선언의 규율 그대로다.
2075
+ *
2076
+ * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
2077
+ * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
2078
+ */
2079
+ {
2080
+ axis: "demandWindows",
2081
+ path: "energy.closed",
2082
+ label: "twin.axis.demandWindows",
2083
+ kind: "instance",
2084
+ source: "state",
2085
+ historical: true,
2086
+ standardClass: {},
2087
+ systems: ["ems"]
2033
2088
  }
2034
2089
  ];
2035
2090
  var TWIN_RELATIONS = [
@@ -5521,6 +5576,11 @@ var MesKernel = class extends FlowEngine {
5521
5576
  // src/ems-kernel.ts
5522
5577
  var DEMAND_WINDOW_MS = 15 * 60 * 1e3;
5523
5578
  var demandWindowStart = (atMs, windowMs = DEMAND_WINDOW_MS) => Math.floor(atMs / windowMs) * windowMs;
5579
+ function windowMsOf(opts) {
5580
+ const raw = typeof opts === "number" ? opts : opts?.windowMs;
5581
+ const n = Number(raw);
5582
+ return Number.isFinite(n) && n > 0 ? n : DEMAND_WINDOW_MS;
5583
+ }
5524
5584
  var KEEP_CLOSED = 96;
5525
5585
  var EmsKernel = class extends FlowEngine {
5526
5586
  points = /* @__PURE__ */ new Map();
@@ -5531,9 +5591,23 @@ var EmsKernel = class extends FlowEngine {
5531
5591
  /** 이 구간에서 이미 제안을 냈나 — 같은 사실을 되풀어 방송하지 않는다(라이브 브리지의 교훈). */
5532
5592
  suggestedFor;
5533
5593
  windowMs;
5534
- constructor(tenantId, policy = firstFitPolicy, windowMs = DEMAND_WINDOW_MS) {
5594
+ /**
5595
+ * ── 세 번째 인자는 **호스트가 정한 자리**다 (2026-08-14 실측으로 고침) ──────
5596
+ *
5597
+ * 호스트는 모든 커널을 한 모양으로 세운다: `new Kernel(tenantId, undefined, productionSpecOf(model))`.
5598
+ * 즉 세 번째 인자는 **도메인 옵션 슬롯**이고 커널마다 뜻이 다르다(야드는 모드, 생산은 명세).
5599
+ *
5600
+ * 처음에 이 자리를 `windowMs: number` 로 받았다가, 라이브에서 **모든 수요 구간이 깨졌다** —
5601
+ * 생산 명세 객체가 창 길이로 들어와 `startMs: null`·`endMs: NaN` 이 됐다. 내 시험은 커널을
5602
+ * `new EmsKernel('t')` 로 직접 세웠기 때문에 그것을 잡지 못했다(호스트와 다른 방식으로 세운 것이
5603
+ * 그 자체로 결함이었다).
5604
+ *
5605
+ * 그래서 **쓸 수 있는 것만 읽는다**: 수면 창 길이로 쓰고, 객체면 `windowMs` 를 찾고, 없으면 기본값
5606
+ * (15분)이다. 모르는 것을 창 길이로 삼지 않는다.
5607
+ */
5608
+ constructor(tenantId, policy = firstFitPolicy, opts) {
5535
5609
  super(tenantId, policy);
5536
- this.windowMs = windowMs;
5610
+ this.windowMs = windowMsOf(opts);
5537
5611
  }
5538
5612
  /**
5539
5613
  * 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
@@ -5733,6 +5807,211 @@ var EmsKernel = class extends FlowEngine {
5733
5807
  }
5734
5808
  };
5735
5809
 
5810
+ // src/energy-ingest.ts
5811
+ function isEnergyRecord(record) {
5812
+ if (!record || typeof record !== "object") return false;
5813
+ const r = record;
5814
+ return typeof r.meterId === "string" && r.meterId.trim().length > 0 && r.epc === void 0;
5815
+ }
5816
+ function ingestEnergyRecords(records, opts) {
5817
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
5818
+ const accepted = [];
5819
+ const rejected = [];
5820
+ let seq = 0;
5821
+ for (const record of arr) {
5822
+ const errors = [];
5823
+ const meterId = String(record?.meterId ?? "").trim();
5824
+ if (!meterId) errors.push("meterId \uC5C6\uC74C \u2014 \uC5B4\uB514\uC758 \uC18C\uBE44\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uB204\uC801\uD560 \uC218 \uC5C6\uB2E4");
5825
+ const at = String(record?.at ?? "").trim() || opts.defaultEventTime;
5826
+ const atMs = at ? Date.parse(at) : Number.NaN;
5827
+ if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uB0A8\uC758 \uC218\uC694 \uAD6C\uAC04\uC5D0 \uC2E4\uB9B0\uB2E4");
5828
+ const num = (v, name) => {
5829
+ if (v === void 0 || v === null || v === "") return void 0;
5830
+ const n = Number(v);
5831
+ if (!Number.isFinite(n)) {
5832
+ errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
5833
+ return void 0;
5834
+ }
5835
+ return n;
5836
+ };
5837
+ const kW = num(record?.kW, "kW");
5838
+ const kWh = num(record?.kWh, "kWh");
5839
+ const powerFactor = num(record?.powerFactor, "powerFactor");
5840
+ if (errors.length) {
5841
+ rejected.push({ record, errors });
5842
+ continue;
5843
+ }
5844
+ const eventTime = new Date(atMs).toISOString();
5845
+ const data = {
5846
+ meterId,
5847
+ /* 계약은 `kW` 를 필수로 두지만 **못 읽은 표본도 사실**이다 — 그 경우 값을 비우고 보낸다.
5848
+ 커널이 「받았지만 부하를 못 읽었다」로 세고, 구간 마감에 그 이유를 싣는다. */
5849
+ ...kW !== void 0 ? { kW } : {},
5850
+ ...kWh !== void 0 ? { kWh } : {},
5851
+ ...powerFactor !== void 0 ? { powerFactor } : {},
5852
+ at: eventTime
5853
+ };
5854
+ accepted.push({
5855
+ eventId: `${opts.tenantId}-energy-${++seq}`,
5856
+ eventType: ENERGY_EVENT.measured,
5857
+ eventTime,
5858
+ tenantId: opts.tenantId,
5859
+ data
5860
+ });
5861
+ }
5862
+ return { accepted, rejected };
5863
+ }
5864
+
5865
+ // src/energy-attribution.ts
5866
+ var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
5867
+ function attributeEnergy(opts) {
5868
+ const byId = new Map(opts.consumers.map((c) => [c.id, c]));
5869
+ const dedicated = /* @__PURE__ */ new Map();
5870
+ for (const c of opts.consumers) if (c.meterId) dedicated.set(c.meterId, c);
5871
+ const shares = [];
5872
+ const unattributed = [];
5873
+ const excluded = [];
5874
+ const overhead = [];
5875
+ for (const pool of opts.pools) {
5876
+ const kWh = Number(pool.kWh);
5877
+ if (!Number.isFinite(kWh)) continue;
5878
+ const own = dedicated.get(pool.meterId);
5879
+ if (own) {
5880
+ shares.push({ consumerId: own.id, kWh, basis: "measured", poolMeterId: pool.meterId });
5881
+ continue;
5882
+ }
5883
+ if (pool.overhead) {
5884
+ const alloc = opts.overheadAllocation;
5885
+ const targets = (alloc?.processConsumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
5886
+ const wOf = (c) => alloc?.weightKind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
5887
+ const sharing2 = alloc?.weightKind === "equal" ? targets : targets.filter((c) => wOf(c) > 0);
5888
+ const ws = sharing2.map(wOf);
5889
+ const total = ws.reduce((a, b) => a + b, 0);
5890
+ if (!alloc || total <= 0) {
5891
+ overhead.push({ poolMeterId: pool.meterId, kWh });
5892
+ continue;
5893
+ }
5894
+ let done = 0;
5895
+ sharing2.forEach((c, i) => {
5896
+ const share = ws[i] / total;
5897
+ const amount = i === sharing2.length - 1 ? kWh - done : kWh * share;
5898
+ done += amount;
5899
+ shares.push({
5900
+ consumerId: c.id,
5901
+ kWh: amount,
5902
+ basis: "apportioned",
5903
+ poolMeterId: pool.meterId,
5904
+ weightKind: alloc.weightKind,
5905
+ weightShare: share,
5906
+ overhead: true
5907
+ });
5908
+ });
5909
+ continue;
5910
+ }
5911
+ const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
5912
+ if (!covered.length) {
5913
+ unattributed.push({ poolMeterId: pool.meterId, kWh, reason: "no-consumers" });
5914
+ continue;
5915
+ }
5916
+ if (covered.length === 1) {
5917
+ shares.push({ consumerId: covered[0].id, kWh, basis: "measured", poolMeterId: pool.meterId });
5918
+ continue;
5919
+ }
5920
+ const kind = opts.weightKind;
5921
+ const weightOf = (c) => kind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
5922
+ const sharing = kind === "equal" ? covered : covered.filter((c) => weightOf(c) > 0);
5923
+ if (kind && sharing.length < covered.length) {
5924
+ for (const c of covered) if (weightOf(c) <= 0) excluded.push({ consumerId: c.id, poolMeterId: pool.meterId, reason: "zero-weight" });
5925
+ }
5926
+ const weights = sharing.map(weightOf);
5927
+ const sum = weights.reduce((a, b) => a + b, 0);
5928
+ if (!kind || sum <= 0) {
5929
+ unattributed.push({
5930
+ poolMeterId: pool.meterId,
5931
+ kWh,
5932
+ reason: "no-weights",
5933
+ consumerIds: covered.map((c) => c.id)
5934
+ });
5935
+ continue;
5936
+ }
5937
+ let given = 0;
5938
+ sharing.forEach((c, i) => {
5939
+ const share = weights[i] / sum;
5940
+ const amount = i === sharing.length - 1 ? kWh - given : kWh * share;
5941
+ given += amount;
5942
+ shares.push({
5943
+ consumerId: c.id,
5944
+ kWh: amount,
5945
+ basis: "apportioned",
5946
+ poolMeterId: pool.meterId,
5947
+ weightKind: kind,
5948
+ weightShare: share
5949
+ });
5950
+ });
5951
+ }
5952
+ const measuredKWh = opts.pools.reduce((a, p) => a + (Number.isFinite(Number(p.kWh)) ? Number(p.kWh) : 0), 0);
5953
+ const attributedKWh = shares.reduce((a, s) => a + s.kWh, 0);
5954
+ const unattributedKWh = unattributed.reduce((a, u) => a + u.kWh, 0);
5955
+ const overheadKWh = overhead.reduce((a, o) => a + o.kWh, 0);
5956
+ if (!near(attributedKWh + unattributedKWh + overheadKWh, measuredKWh, 1e-6)) {
5957
+ throw new Error(
5958
+ `energy attribution lost or created energy: measured=${measuredKWh} attributed=${attributedKWh} unattributed=${unattributedKWh} overhead=${overheadKWh}`
5959
+ );
5960
+ }
5961
+ return { shares, unattributed, excluded, overhead, totals: { measuredKWh, attributedKWh, unattributedKWh, overheadKWh } };
5962
+ }
5963
+ var UNIT = {
5964
+ output: "kWh/unit",
5965
+ runtimeHours: "kW",
5966
+ area: "kWh/m2"
5967
+ };
5968
+ function energyIntensity(input) {
5969
+ const kWh = Number(input.kWh);
5970
+ if (!Number.isFinite(kWh)) return { value: null, reason: "no-energy" };
5971
+ const den = Number(input.denominator?.value);
5972
+ if (!Number.isFinite(den)) return { value: null, reason: "no-denominator" };
5973
+ const ew = input.energyWindow;
5974
+ const dw = input.denominator?.window;
5975
+ if (ew && dw && (ew.startMs !== dw.startMs || ew.endMs !== dw.endMs)) return { value: null, reason: "window-mismatch" };
5976
+ if (den === 0) return { value: null, reason: "zero-denominator" };
5977
+ const window = ew ?? dw;
5978
+ return {
5979
+ value: kWh / den,
5980
+ unit: UNIT[input.denominator.kind] ?? "kWh",
5981
+ kWh,
5982
+ denominator: den,
5983
+ ...window ? { window } : { window: { startMs: 0, endMs: 0 } }
5984
+ };
5985
+ }
5986
+ function energyOfWindows(windows, range) {
5987
+ const inRange = (w) => !range || w.startMs >= range.startMs && w.endMs <= range.endMs;
5988
+ let kWh = 0;
5989
+ let counted = 0;
5990
+ let skipped = 0;
5991
+ let first;
5992
+ let last;
5993
+ for (const w of windows ?? []) {
5994
+ if (!inRange(w)) continue;
5995
+ const mean = Number(w.meanKW);
5996
+ const hours = (Number(w.endMs) - Number(w.startMs)) / 36e5;
5997
+ if (!Number.isFinite(mean) || !Number.isFinite(hours) || hours <= 0) {
5998
+ skipped++;
5999
+ continue;
6000
+ }
6001
+ kWh += mean * hours;
6002
+ counted++;
6003
+ if (first === void 0 || w.startMs < first) first = w.startMs;
6004
+ if (last === void 0 || w.endMs > last) last = w.endMs;
6005
+ }
6006
+ return {
6007
+ ...counted > 0 ? { kWh } : {},
6008
+ basis: "mean-kw",
6009
+ counted,
6010
+ skipped,
6011
+ ...first !== void 0 && last !== void 0 ? { window: { startMs: first, endMs: last } } : {}
6012
+ };
6013
+ }
6014
+
5736
6015
  // src/vocabulary.ts
5737
6016
  var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
5738
6017
  var VOCABULARY_EXCEPTIONS = [
@@ -5835,6 +6114,7 @@ function retiredVocabularyIn(line) {
5835
6114
  activeShiftOf,
5836
6115
  aggregationEvent,
5837
6116
  analyzeCapacity,
6117
+ attributeEnergy,
5838
6118
  axesOfSystem,
5839
6119
  axisAppliesTo,
5840
6120
  axisInfo,
@@ -5852,6 +6132,8 @@ function retiredVocabularyIn(line) {
5852
6132
  documentPath,
5853
6133
  dueStatusOf,
5854
6134
  effectivityAt,
6135
+ energyIntensity,
6136
+ energyOfWindows,
5855
6137
  fefoPolicy,
5856
6138
  firstFitPolicy,
5857
6139
  foldJobResponses,
@@ -5862,6 +6144,8 @@ function retiredVocabularyIn(line) {
5862
6144
  inWorkCalendar,
5863
6145
  inWorkCalendarAt,
5864
6146
  ingest,
6147
+ ingestEnergyRecords,
6148
+ isEnergyRecord,
5865
6149
  isEquipmentLevel,
5866
6150
  isoDurationHours,
5867
6151
  itemKeyOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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": {