@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.
- package/dist/contract.d.ts +61 -0
- package/dist/domain-catalog.d.ts +6 -2
- package/dist/domain-catalog.js +21 -1
- package/dist/ems-kernel.d.ts +26 -56
- package/dist/ems-kernel.js +84 -7
- package/dist/ems-profile.d.ts +1 -1
- package/dist/ems-profile.js +23 -1
- package/dist/energy-attribution.d.ts +208 -0
- package/dist/energy-attribution.js +229 -0
- package/dist/energy-ingest.d.ts +39 -0
- package/dist/energy-ingest.js +91 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist-cjs/index.cjs +328 -4
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -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;
|
package/dist/domain-catalog.d.ts
CHANGED
|
@@ -87,8 +87,12 @@ export interface TwinAxisInfo {
|
|
|
87
87
|
* board 안에서의 자리. 최상위면 `axis` 와 같고, 중첩이면 경로다
|
|
88
88
|
* (`productionSpec.definition.recipes`).
|
|
89
89
|
*
|
|
90
|
-
* **`source
|
|
91
|
-
*
|
|
90
|
+
* **`source` 가 가리키는 자료 안의 경로다** — 예전에는 「`document` 일 때만 있다」고 못 박았는데,
|
|
91
|
+
* 상태 축도 중첩될 수 있다는 것이 에너지에서 드러났다(수요 구간은 `state.energy.closed` 에 산다).
|
|
92
|
+
* 그때 축 이름을 상태의 최상위 키로 맞추려면 같은 배열을 두 자리에 실어야 했다(방송이 그만큼 커진다).
|
|
93
|
+
*
|
|
94
|
+
* 규칙은 하나다: **경로가 있으면 그 경로로 읽고, 없으면 축 이름으로 읽는다.** 빈 문자열로 두지
|
|
95
|
+
* 않는다 — 소비처가 자료의 뿌리를 읽고 통째로 잘못된 답을 만든다.
|
|
92
96
|
*
|
|
93
97
|
* **레시피·라우트는 저장상 `productionSpec` 안에 있지만 개념으로는 1급**이다 — 사람은
|
|
94
98
|
* "레시피" 를 찾지 "생산 정의 안의 레시피" 를 찾지 않는다. 저장 위치가 개념을 가두면
|
package/dist/domain-catalog.js
CHANGED
|
@@ -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 = [
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
|
@@ -77,6 +39,14 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
77
39
|
* (기본값을 지어내면 그 뒤 모든 판정이 거짓 위에 선다).
|
|
78
40
|
*/
|
|
79
41
|
private declaredContractKW;
|
|
42
|
+
/**
|
|
43
|
+
* **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
|
|
44
|
+
*
|
|
45
|
+
* 계층 계량(수전 ⊃ 분기)에서 전부 더하면 이중 계상이 된다. 뿌리만 더하면 현장의 실제 부하가 된다.
|
|
46
|
+
* 모델이 그 계량기의 자리를 모르면 뿌리로 본다(모르는 것을 빼면 부하가 조용히 작아진다 — 그것이
|
|
47
|
+
* 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
|
|
48
|
+
*/
|
|
49
|
+
private rootMeterIds;
|
|
80
50
|
/**
|
|
81
51
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
82
52
|
*
|
package/dist/ems-kernel.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
72
|
+
this.windowMs = windowMsOf(opts);
|
|
50
73
|
}
|
|
51
74
|
/**
|
|
52
75
|
* 계약전력 — **현장이 선언한 자리 속성**에서 읽는다(`EMS_PROPERTY.contractKW`).
|
|
@@ -70,6 +93,51 @@ export class EmsKernel extends FlowEngine {
|
|
|
70
93
|
}
|
|
71
94
|
return max;
|
|
72
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* **뿌리 계량기** — 조상 중에 계량된 자리가 없는 계량 지점들.
|
|
98
|
+
*
|
|
99
|
+
* 계층 계량(수전 ⊃ 분기)에서 전부 더하면 이중 계상이 된다. 뿌리만 더하면 현장의 실제 부하가 된다.
|
|
100
|
+
* 모델이 그 계량기의 자리를 모르면 뿌리로 본다(모르는 것을 빼면 부하가 조용히 작아진다 — 그것이
|
|
101
|
+
* 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
|
|
102
|
+
*/
|
|
103
|
+
rootMeterIds() {
|
|
104
|
+
const locOf = new Map();
|
|
105
|
+
for (const e of this.boardDef?.equipment ?? [])
|
|
106
|
+
locOf.set(e.id, e.homeLocation);
|
|
107
|
+
const parentOf = new Map();
|
|
108
|
+
for (const l of this.boardDef?.locations ?? [])
|
|
109
|
+
parentOf.set(l.id, l.parentId);
|
|
110
|
+
/* 계량되고 있는 자리들 — 표본이 실제로 온 계량기의 자리만 센다(선언만 있고 값이 없는 계량기는
|
|
111
|
+
부하에 기여하지 않으므로 계층 판정에서도 제외한다). */
|
|
112
|
+
const meteredLocs = new Set();
|
|
113
|
+
for (const id of this.points.keys()) {
|
|
114
|
+
const loc = locOf.get(id);
|
|
115
|
+
if (loc)
|
|
116
|
+
meteredLocs.add(loc);
|
|
117
|
+
}
|
|
118
|
+
const out = new Set();
|
|
119
|
+
for (const id of this.points.keys()) {
|
|
120
|
+
const loc = locOf.get(id);
|
|
121
|
+
if (!loc) {
|
|
122
|
+
out.add(id);
|
|
123
|
+
continue;
|
|
124
|
+
} // 자리를 모르면 뿌리로 본다
|
|
125
|
+
let cur = parentOf.get(loc);
|
|
126
|
+
let root = true;
|
|
127
|
+
const seen = new Set([loc]);
|
|
128
|
+
while (cur && !seen.has(cur)) {
|
|
129
|
+
if (meteredLocs.has(cur)) {
|
|
130
|
+
root = false;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
seen.add(cur);
|
|
134
|
+
cur = parentOf.get(cur);
|
|
135
|
+
}
|
|
136
|
+
if (root)
|
|
137
|
+
out.add(id);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
73
141
|
/**
|
|
74
142
|
* 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
|
|
75
143
|
*
|
|
@@ -115,12 +183,21 @@ export class EmsKernel extends FlowEngine {
|
|
|
115
183
|
const w = this.open;
|
|
116
184
|
if (kW !== undefined) {
|
|
117
185
|
/*
|
|
118
|
-
* 구간의 부하는
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
186
|
+
* ── 구간의 부하는 **뿌리 계량기의 합**이다 (2026-08-14 실측으로 고침) ────────
|
|
187
|
+
*
|
|
188
|
+
* 처음에는 「지점의 합」이라고만 했다. 그런데 실제 현장은 **계층 계량**이다: 수전 계량기가 공장
|
|
189
|
+
* 전체를 재고, 분기 계량기가 그 안의 라인을 나눠 잰다. 둘을 다 더하면 **이중 계상**이 되고,
|
|
190
|
+
* 목 데이터로 재 보니 실제 3,855kW 인 현장이 7,414kW 로 읽혀 **계약(4,000kW)을 넘었다는 거짓
|
|
191
|
+
* 판정**이 나왔다. 요금은 수전 지점에서 매겨지므로 그 값이 사실이다.
|
|
192
|
+
*
|
|
193
|
+
* 그래서 **뿌리만 더한다**: 자기 자리의 조상 중에 계량된 자리가 없는 계량기가 뿌리다. 수전만
|
|
194
|
+
* 계량하는 현장은 그것 하나, 분기만 계량하는 현장은 분기들의 합, 둘 다 계량하면 수전 하나.
|
|
195
|
+
*
|
|
196
|
+
* 모델이 그 계량기를 모르면(설비 선언에 없다) 뿌리로 본다 — 모르는 것을 빼면 그만큼 부하가
|
|
197
|
+
* 조용히 작아지고, 작아진 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
|
|
122
198
|
*/
|
|
123
|
-
const
|
|
199
|
+
const roots = this.rootMeterIds();
|
|
200
|
+
const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
|
|
124
201
|
if (w.maxKW === undefined || total > w.maxKW)
|
|
125
202
|
w.maxKW = total;
|
|
126
203
|
w.samples++;
|
package/dist/ems-profile.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/ems-profile.js
CHANGED
|
@@ -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;
|