@operato/twin-kernel 0.7.5 → 0.7.7
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 +54 -1
- package/dist/contract.js +6 -0
- package/dist/ems-kernel.d.ts +15 -0
- package/dist/ems-kernel.js +45 -0
- package/dist/energy-ingest.d.ts +23 -0
- package/dist/energy-ingest.js +103 -0
- package/dist/flow-engine.js +18 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist-cjs/index.cjs +145 -1
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -842,6 +842,26 @@ export interface EquipmentState extends EffectivePeriod {
|
|
|
842
842
|
* 셋을 합치면 "왜 안 움직이나" 에 답할 수 없다(고쳐야 하나·풀어야 하나·기다려야 하나).
|
|
843
843
|
*/
|
|
844
844
|
offShift?: boolean;
|
|
845
|
+
/** generating — 만든 전력·역송(kW). */
|
|
846
|
+
generatedKW?: number;
|
|
847
|
+
exportKW?: number;
|
|
848
|
+
/** storing — 충전율(%)·충전·방전(kW). 충전과 방전을 나눈다(손실·수명 판단이 그 둘을 구별한다). */
|
|
849
|
+
soc?: number;
|
|
850
|
+
chargeKW?: number;
|
|
851
|
+
dischargeKW?: number;
|
|
852
|
+
/** curtailable — 줄일 수 있나, 그리고 최소 유지(kW). 그 아래로 내리면 공정이 죽는다. */
|
|
853
|
+
curtailable?: boolean;
|
|
854
|
+
minKW?: number;
|
|
855
|
+
/** switching — 개폐 위치(IEC 61850 `Pos`). 과도·불량을 열림/닫힘으로 반올림하지 않는다. */
|
|
856
|
+
position?: 'open' | 'closed' | 'intermediate' | 'bad';
|
|
857
|
+
/**
|
|
858
|
+
* 위 값들을 **언제 들었나** — 값과 함께 다녀야 하는 사실.
|
|
859
|
+
*
|
|
860
|
+
* 에너지에서 가장 위험한 화면은 멈춘 값을 지금 값처럼 보여 주는 것이다. 통신이 끊긴 설비는 마지막
|
|
861
|
+
* 상태를 그대로 들고 있고, 시각이 없으면 화면은 그것을 「지금」으로 그린다. 값만 적고 시각을
|
|
862
|
+
* 버리면 그 판단을 아무도 할 수 없다.
|
|
863
|
+
*/
|
|
864
|
+
measuredAt?: ISOTime;
|
|
845
865
|
/**
|
|
846
866
|
* 유효 기간 밖이라 이 시각의 모델에 없다 — **네 번째 이유**(§Effectivity).
|
|
847
867
|
* 도입 예정(`not-yet`)과 폐기(`expired`)를 구별한다. 유효하면 값이 없다.
|
|
@@ -1218,7 +1238,13 @@ export interface EnergyState {
|
|
|
1218
1238
|
/** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
|
|
1219
1239
|
peakSince?: {
|
|
1220
1240
|
kW: number;
|
|
1221
|
-
windowStartMs: number;
|
|
1241
|
+
windowStartMs: number; /**
|
|
1242
|
+
* 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
|
|
1243
|
+
*
|
|
1244
|
+
* 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
|
|
1245
|
+
* 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
|
|
1246
|
+
*/
|
|
1247
|
+
unknownEquipment?: number;
|
|
1222
1248
|
};
|
|
1223
1249
|
/** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
|
|
1224
1250
|
contractKW?: number;
|
|
@@ -1428,6 +1454,12 @@ export declare const OP_EVENT: {
|
|
|
1428
1454
|
export declare const ENERGY_EVENT: {
|
|
1429
1455
|
/** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
|
|
1430
1456
|
readonly measured: "energy.measured";
|
|
1457
|
+
/**
|
|
1458
|
+
* 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
|
|
1459
|
+
*
|
|
1460
|
+
* 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
|
|
1461
|
+
*/
|
|
1462
|
+
readonly equipment: "energy.equipment";
|
|
1431
1463
|
/** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
|
|
1432
1464
|
readonly demandWindow: "energy.demand.window";
|
|
1433
1465
|
/**
|
|
@@ -1455,6 +1487,27 @@ export declare const ENERGY_EVENT: {
|
|
|
1455
1487
|
readonly drSuggested: "energy.dr.suggested";
|
|
1456
1488
|
};
|
|
1457
1489
|
export type EnergyEventType = (typeof ENERGY_EVENT)[keyof typeof ENERGY_EVENT];
|
|
1490
|
+
/**
|
|
1491
|
+
* 설비가 낸 **자기 에너지 상태** — 계량이 아닌 능력들의 값.
|
|
1492
|
+
*
|
|
1493
|
+
* 계량(`energy.measured`)과 갈라 두는 이유: 계량은 **구간에 누적되는 표본**이고, 이것들은 **그 설비의
|
|
1494
|
+
* 지금 상태**다. 같은 문으로 넣으면 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
|
|
1495
|
+
*
|
|
1496
|
+
* 한 봉투에 여러 능력의 값이 함께 올 수 있다(원 시스템이 한 번에 준다) — 각 값은 그것을 선언한 능력의
|
|
1497
|
+
* 필드로만 들어간다. 없는 값은 **보내지 않는다**(0 을 보내면 「그렇게 측정됐다」가 된다).
|
|
1498
|
+
*/
|
|
1499
|
+
export interface EnergyEquipmentData {
|
|
1500
|
+
equipmentId: string;
|
|
1501
|
+
at: ISOTime;
|
|
1502
|
+
generatedKW?: number;
|
|
1503
|
+
exportKW?: number;
|
|
1504
|
+
soc?: number;
|
|
1505
|
+
chargeKW?: number;
|
|
1506
|
+
dischargeKW?: number;
|
|
1507
|
+
curtailable?: boolean;
|
|
1508
|
+
minKW?: number;
|
|
1509
|
+
position?: 'open' | 'closed' | 'intermediate' | 'bad';
|
|
1510
|
+
}
|
|
1458
1511
|
/** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
|
|
1459
1512
|
export interface EnergyMeasuredData {
|
|
1460
1513
|
/** 계량 지점(설비 id). */
|
package/dist/contract.js
CHANGED
|
@@ -631,6 +631,12 @@ export const OP_EVENT = {
|
|
|
631
631
|
export const ENERGY_EVENT = {
|
|
632
632
|
/** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
|
|
633
633
|
measured: 'energy.measured',
|
|
634
|
+
/**
|
|
635
|
+
* 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
|
|
636
|
+
*
|
|
637
|
+
* 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
|
|
638
|
+
*/
|
|
639
|
+
equipment: 'energy.equipment',
|
|
634
640
|
/** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
|
|
635
641
|
demandWindow: 'energy.demand.window',
|
|
636
642
|
/**
|
package/dist/ems-kernel.d.ts
CHANGED
|
@@ -70,6 +70,21 @@ export declare class EmsKernel extends FlowEngine {
|
|
|
70
70
|
* 적는다(상위 `apply` 의 재방출 규약과 같은 이유).
|
|
71
71
|
*/
|
|
72
72
|
apply(envelope: CanonicalEnvelope): void;
|
|
73
|
+
/**
|
|
74
|
+
* 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
|
|
75
|
+
*
|
|
76
|
+
* ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
|
|
77
|
+
* 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
|
|
78
|
+
* 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
|
|
79
|
+
*
|
|
80
|
+
* 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
|
|
81
|
+
* 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
|
|
82
|
+
*
|
|
83
|
+
* 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
|
|
84
|
+
*/
|
|
85
|
+
private applyEquipmentEnergy;
|
|
86
|
+
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
87
|
+
private unknownEquipmentReports;
|
|
73
88
|
private ingestMeasured;
|
|
74
89
|
private openWindow;
|
|
75
90
|
/**
|
package/dist/ems-kernel.js
CHANGED
|
@@ -173,8 +173,52 @@ export class EmsKernel extends FlowEngine {
|
|
|
173
173
|
this.ingestMeasured(envelope);
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
+
if (envelope.eventType === ENERGY_EVENT.equipment) {
|
|
177
|
+
this.applyEquipmentEnergy(envelope);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
176
180
|
super.apply(envelope);
|
|
177
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
|
|
184
|
+
*
|
|
185
|
+
* ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
|
|
186
|
+
* 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
|
|
187
|
+
* 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
|
|
188
|
+
*
|
|
189
|
+
* 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
|
|
190
|
+
* 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
|
|
191
|
+
*
|
|
192
|
+
* 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
|
|
193
|
+
*/
|
|
194
|
+
applyEquipmentEnergy(envelope) {
|
|
195
|
+
const d = envelope.data;
|
|
196
|
+
const id = String(d?.equipmentId ?? '').trim();
|
|
197
|
+
const eq = id ? this.equipment.get(id) : undefined;
|
|
198
|
+
if (!eq) {
|
|
199
|
+
this.unknownEquipmentReports++;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const put = (k) => {
|
|
203
|
+
const v = d[k];
|
|
204
|
+
if (v !== undefined)
|
|
205
|
+
eq[k] = v;
|
|
206
|
+
};
|
|
207
|
+
put('generatedKW');
|
|
208
|
+
put('exportKW');
|
|
209
|
+
put('soc');
|
|
210
|
+
put('chargeKW');
|
|
211
|
+
put('dischargeKW');
|
|
212
|
+
put('curtailable');
|
|
213
|
+
put('minKW');
|
|
214
|
+
put('position');
|
|
215
|
+
/* **값과 함께 시각을 남긴다** — 없으면 화면이 멈춘 값을 지금 값으로 그린다(계량 지점과 같은 규율). */
|
|
216
|
+
if (d?.at)
|
|
217
|
+
eq.measuredAt = d.at;
|
|
218
|
+
this.revision++;
|
|
219
|
+
}
|
|
220
|
+
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
221
|
+
unknownEquipmentReports = 0;
|
|
178
222
|
ingestMeasured(envelope) {
|
|
179
223
|
const d = envelope.data;
|
|
180
224
|
/* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
|
|
@@ -459,6 +503,7 @@ export class EmsKernel extends FlowEngine {
|
|
|
459
503
|
closed: this.closed.map(w => ({ ...w })),
|
|
460
504
|
closedTotal: this.closedTotal,
|
|
461
505
|
...(this.peak ? { peakSince: { ...this.peak } } : {}),
|
|
506
|
+
...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
|
|
462
507
|
...(contractKW !== undefined ? { contractKW } : {}),
|
|
463
508
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
464
509
|
...(this.flowRequests.size
|
package/dist/energy-ingest.d.ts
CHANGED
|
@@ -37,3 +37,26 @@ export declare function isEnergyRecord(record: unknown): boolean;
|
|
|
37
37
|
* 페이로드를 먼저 보고, 저널·시간여행은 봉투를 본다 — 둘이 다르면 같은 사실이 두 시각을 갖는다.
|
|
38
38
|
*/
|
|
39
39
|
export declare function ingestEnergyRecords(records: EnergyRecord | EnergyRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
|
|
40
|
+
/** 정규 설비 에너지 레코드 — 커넥터가 이 모양으로 맞춰 준다(필드 이름이 계약이다). */
|
|
41
|
+
export interface EnergyEquipmentRecord {
|
|
42
|
+
equipmentId: string;
|
|
43
|
+
at?: string;
|
|
44
|
+
generatedKW?: number;
|
|
45
|
+
exportKW?: number;
|
|
46
|
+
soc?: number;
|
|
47
|
+
chargeKW?: number;
|
|
48
|
+
dischargeKW?: number;
|
|
49
|
+
curtailable?: boolean;
|
|
50
|
+
minKW?: number;
|
|
51
|
+
position?: string;
|
|
52
|
+
}
|
|
53
|
+
/** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
|
|
54
|
+
export declare function isEnergyEquipmentRecord(record: unknown): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
|
|
57
|
+
* 이유와 함께 돌려준다.
|
|
58
|
+
*
|
|
59
|
+
* **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
|
|
60
|
+
* 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
|
|
61
|
+
*/
|
|
62
|
+
export declare function ingestEnergyEquipmentRecords(records: EnergyEquipmentRecord | EnergyEquipmentRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
|
package/dist/energy-ingest.js
CHANGED
|
@@ -89,3 +89,106 @@ export function ingestEnergyRecords(records, opts) {
|
|
|
89
89
|
}
|
|
90
90
|
return { accepted, rejected };
|
|
91
91
|
}
|
|
92
|
+
/** 개폐 위치의 값 — 표준이 정한 넷. 그 밖의 낱말은 받지 않는다(뭉개면 없는 확신이 생긴다). */
|
|
93
|
+
const POSITIONS = new Set(['open', 'closed', 'intermediate', 'bad']);
|
|
94
|
+
/** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
|
|
95
|
+
export function isEnergyEquipmentRecord(record) {
|
|
96
|
+
if (!record || typeof record !== 'object')
|
|
97
|
+
return false;
|
|
98
|
+
const r = record;
|
|
99
|
+
return typeof r.equipmentId === 'string' && r.equipmentId.trim().length > 0 && r.epc === undefined && r.meterId === undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
|
|
103
|
+
* 이유와 함께 돌려준다.
|
|
104
|
+
*
|
|
105
|
+
* **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
|
|
106
|
+
* 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
|
|
107
|
+
*/
|
|
108
|
+
export function ingestEnergyEquipmentRecords(records, opts) {
|
|
109
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
110
|
+
const accepted = [];
|
|
111
|
+
const rejected = [];
|
|
112
|
+
let seq = 0;
|
|
113
|
+
for (const record of arr) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
const r = record;
|
|
116
|
+
const equipmentId = String(r?.equipmentId ?? '').trim();
|
|
117
|
+
if (!equipmentId)
|
|
118
|
+
errors.push('equipmentId 없음 — 어느 설비의 상태인지 모르는 값은 실을 수 없다');
|
|
119
|
+
const at = String(r?.at ?? '').trim() || opts.defaultEventTime;
|
|
120
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
121
|
+
if (!Number.isFinite(atMs))
|
|
122
|
+
errors.push('at 없음/형식 오류 — 지금 시각으로 메우면 언제의 상태인지 알 수 없다');
|
|
123
|
+
const num = (v, name, min, max) => {
|
|
124
|
+
if (v === undefined || v === null || v === '')
|
|
125
|
+
return undefined;
|
|
126
|
+
const n = Number(v);
|
|
127
|
+
if (!Number.isFinite(n)) {
|
|
128
|
+
errors.push(`${name} 가 수가 아니다: ${JSON.stringify(v)}`);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (min !== undefined && n < min) {
|
|
132
|
+
errors.push(`${name} 가 ${min} 보다 작다: ${n}`);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (max !== undefined && n > max) {
|
|
136
|
+
errors.push(`${name} 가 ${max} 보다 크다: ${n}`);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
};
|
|
141
|
+
const generatedKW = num(r?.generatedKW, 'generatedKW', 0);
|
|
142
|
+
const exportKW = num(r?.exportKW, 'exportKW');
|
|
143
|
+
/* 충전율은 백분율이다 — 범위를 벗어난 값은 받지 않는다(단위를 잘못 매핑한 커넥터를 조용히 통과시키면
|
|
144
|
+
「배터리가 380% 찼다」가 화면에 뜬다). */
|
|
145
|
+
const soc = num(r?.soc, 'soc', 0, 100);
|
|
146
|
+
const chargeKW = num(r?.chargeKW, 'chargeKW', 0);
|
|
147
|
+
const dischargeKW = num(r?.dischargeKW, 'dischargeKW', 0);
|
|
148
|
+
const minKW = num(r?.minKW, 'minKW', 0);
|
|
149
|
+
let curtailable;
|
|
150
|
+
if (r?.curtailable !== undefined && r?.curtailable !== null) {
|
|
151
|
+
if (typeof r.curtailable !== 'boolean')
|
|
152
|
+
errors.push(`curtailable 가 참/거짓이 아니다: ${JSON.stringify(r.curtailable)}`);
|
|
153
|
+
else
|
|
154
|
+
curtailable = r.curtailable;
|
|
155
|
+
}
|
|
156
|
+
let position;
|
|
157
|
+
if (r?.position !== undefined && r?.position !== null && r?.position !== '') {
|
|
158
|
+
const p = String(r.position);
|
|
159
|
+
if (!POSITIONS.has(p))
|
|
160
|
+
errors.push(`position 이 표준 값이 아니다(open·closed·intermediate·bad): ${JSON.stringify(r.position)}`);
|
|
161
|
+
else
|
|
162
|
+
position = p;
|
|
163
|
+
}
|
|
164
|
+
const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
|
|
165
|
+
if (!errors.length && Object.values(values).every(v => v === undefined)) {
|
|
166
|
+
errors.push('바꿀 상태가 하나도 없다 — 값 없는 보고는 사실로 적을 것이 없다');
|
|
167
|
+
}
|
|
168
|
+
if (errors.length) {
|
|
169
|
+
rejected.push({ record, errors });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const eventTime = new Date(atMs).toISOString();
|
|
173
|
+
const data = {
|
|
174
|
+
equipmentId,
|
|
175
|
+
at: eventTime,
|
|
176
|
+
...(generatedKW !== undefined ? { generatedKW } : {}),
|
|
177
|
+
...(exportKW !== undefined ? { exportKW } : {}),
|
|
178
|
+
...(soc !== undefined ? { soc } : {}),
|
|
179
|
+
...(chargeKW !== undefined ? { chargeKW } : {}),
|
|
180
|
+
...(dischargeKW !== undefined ? { dischargeKW } : {}),
|
|
181
|
+
...(curtailable !== undefined ? { curtailable } : {}),
|
|
182
|
+
...(minKW !== undefined ? { minKW } : {}),
|
|
183
|
+
...(position !== undefined ? { position } : {})
|
|
184
|
+
};
|
|
185
|
+
accepted.push({
|
|
186
|
+
eventId: `${opts.tenantId}-energy-eq-${++seq}`,
|
|
187
|
+
eventType: ENERGY_EVENT.equipment,
|
|
188
|
+
eventTime,
|
|
189
|
+
tenantId: opts.tenantId,
|
|
190
|
+
data
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return { accepted, rejected };
|
|
194
|
+
}
|
package/dist/flow-engine.js
CHANGED
|
@@ -207,6 +207,23 @@ export function computeOee(c, nowMs) {
|
|
|
207
207
|
runMs: c.runMs, setupMs: c.setupMs, downMs: c.downMs, idleMs: Math.max(0, uptime - c.runMs), goodCount: c.goodCount, scrapCount: c.scrapCount
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* 에너지 능력이 선언한 상태 필드를 스냅샷에 싣는다 — **온 것만.**
|
|
212
|
+
*
|
|
213
|
+
* 능력 계약이 이 필드들을 관측으로 선언해 두었는데 투영이 싣지 않으면, 값이 커널 안에서만 살고
|
|
214
|
+
* 화면·성과·저널 어디에도 나타나지 않는다(실제로 그랬다: 값을 적었는데 스냅샷에 없었다).
|
|
215
|
+
*
|
|
216
|
+
* 값이 없으면 **키를 만들지 않는다** — 에너지에서 「모른다」와 「0 이다」는 요금이 갈리는 차이다.
|
|
217
|
+
*/
|
|
218
|
+
function energyFieldsOf(m) {
|
|
219
|
+
const out = {};
|
|
220
|
+
const src = m;
|
|
221
|
+
for (const k of ['generatedKW', 'exportKW', 'soc', 'chargeKW', 'dischargeKW', 'curtailable', 'minKW', 'position', 'measuredAt']) {
|
|
222
|
+
if (src[k] !== undefined)
|
|
223
|
+
out[k] = src[k];
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
210
227
|
export class FlowEngine {
|
|
211
228
|
tenantId;
|
|
212
229
|
locations = new Map();
|
|
@@ -857,7 +874,7 @@ export class FlowEngine {
|
|
|
857
874
|
}),
|
|
858
875
|
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
859
876
|
equipment: [...this.equipment.values()].map(m => {
|
|
860
|
-
const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
|
|
877
|
+
const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
|
|
861
878
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
862
879
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
863
880
|
s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +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 { ingestEnergyRecords, isEnergyRecord } from './energy-ingest.ts';
|
|
32
|
+
export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from './energy-ingest.ts';
|
|
33
33
|
export { attributeEnergy, energyIntensity, energyOfWindows } from './energy-attribution.ts';
|
|
34
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';
|
|
35
|
+
export type { EnergyRecord, EnergyEquipmentRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
|
|
36
36
|
export * from './vocabulary.ts';
|
package/dist/index.js
CHANGED
|
@@ -29,7 +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";
|
|
32
|
+
export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from "./energy-ingest.js";
|
|
33
33
|
export { attributeEnergy, energyIntensity, energyOfWindows } from "./energy-attribution.js";
|
|
34
34
|
/* 에너지 상태 타입은 **계약**에 있다(상태의 모양은 계약이다) — contract 의 `export *` 가 이미 낸다. */
|
|
35
35
|
export * from "./vocabulary.js";
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -107,7 +107,9 @@ __export(index_exports, {
|
|
|
107
107
|
inWorkCalendar: () => inWorkCalendar,
|
|
108
108
|
inWorkCalendarAt: () => inWorkCalendarAt,
|
|
109
109
|
ingest: () => ingest,
|
|
110
|
+
ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
|
|
110
111
|
ingestEnergyRecords: () => ingestEnergyRecords,
|
|
112
|
+
isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
|
|
111
113
|
isEnergyRecord: () => isEnergyRecord,
|
|
112
114
|
isEquipmentLevel: () => isEquipmentLevel,
|
|
113
115
|
isoDurationHours: () => isoDurationHours,
|
|
@@ -483,6 +485,12 @@ var OP_EVENT = {
|
|
|
483
485
|
var ENERGY_EVENT = {
|
|
484
486
|
/** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
|
|
485
487
|
measured: "energy.measured",
|
|
488
|
+
/**
|
|
489
|
+
* 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
|
|
490
|
+
*
|
|
491
|
+
* 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
|
|
492
|
+
*/
|
|
493
|
+
equipment: "energy.equipment",
|
|
486
494
|
/** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
|
|
487
495
|
demandWindow: "energy.demand.window",
|
|
488
496
|
/**
|
|
@@ -2789,6 +2797,14 @@ function computeOee(c, nowMs) {
|
|
|
2789
2797
|
scrapCount: c.scrapCount
|
|
2790
2798
|
};
|
|
2791
2799
|
}
|
|
2800
|
+
function energyFieldsOf(m) {
|
|
2801
|
+
const out = {};
|
|
2802
|
+
const src = m;
|
|
2803
|
+
for (const k of ["generatedKW", "exportKW", "soc", "chargeKW", "dischargeKW", "curtailable", "minKW", "position", "measuredAt"]) {
|
|
2804
|
+
if (src[k] !== void 0) out[k] = src[k];
|
|
2805
|
+
}
|
|
2806
|
+
return out;
|
|
2807
|
+
}
|
|
2792
2808
|
var FlowEngine = class {
|
|
2793
2809
|
tenantId;
|
|
2794
2810
|
locations = /* @__PURE__ */ new Map();
|
|
@@ -3389,7 +3405,7 @@ var FlowEngine = class {
|
|
|
3389
3405
|
}),
|
|
3390
3406
|
items: [...this.items.values()].map((i) => this.itemState(i)),
|
|
3391
3407
|
equipment: [...this.equipment.values()].map((m) => {
|
|
3392
|
-
const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {} };
|
|
3408
|
+
const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
|
|
3393
3409
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
3394
3410
|
if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
|
|
3395
3411
|
return s;
|
|
@@ -5756,8 +5772,49 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5756
5772
|
this.ingestMeasured(envelope);
|
|
5757
5773
|
return;
|
|
5758
5774
|
}
|
|
5775
|
+
if (envelope.eventType === ENERGY_EVENT.equipment) {
|
|
5776
|
+
this.applyEquipmentEnergy(envelope);
|
|
5777
|
+
return;
|
|
5778
|
+
}
|
|
5759
5779
|
super.apply(envelope);
|
|
5760
5780
|
}
|
|
5781
|
+
/**
|
|
5782
|
+
* 설비가 낸 자기 에너지 상태를 그 설비에 적는다 — 발전·저장·감축 여지·개폐 위치.
|
|
5783
|
+
*
|
|
5784
|
+
* ── 계량과 갈라 두는 이유 ──────────────────────────────────────────────────
|
|
5785
|
+
* 계량은 **수요 구간에 누적**되는 표본이고, 이것들은 그 설비의 **지금**이다. 같은 문으로 넣으면
|
|
5786
|
+
* 태양광 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
|
|
5787
|
+
*
|
|
5788
|
+
* 모르는 설비의 상태는 **버리지 않고 세어 둔다** — 원천에 우리 모델이 모르는 설비가 있다는 것은
|
|
5789
|
+
* 그 자체로 알아야 할 사실이다(상위 커널의 `unhandled` 와 같은 규율).
|
|
5790
|
+
*
|
|
5791
|
+
* 온 값만 덮는다: 보내지 않은 필드는 그대로 둔다(없는 값을 0 으로 만들지 않는다).
|
|
5792
|
+
*/
|
|
5793
|
+
applyEquipmentEnergy(envelope) {
|
|
5794
|
+
const d = envelope.data;
|
|
5795
|
+
const id = String(d?.equipmentId ?? "").trim();
|
|
5796
|
+
const eq = id ? this.equipment.get(id) : void 0;
|
|
5797
|
+
if (!eq) {
|
|
5798
|
+
this.unknownEquipmentReports++;
|
|
5799
|
+
return;
|
|
5800
|
+
}
|
|
5801
|
+
const put = (k) => {
|
|
5802
|
+
const v = d[k];
|
|
5803
|
+
if (v !== void 0) eq[k] = v;
|
|
5804
|
+
};
|
|
5805
|
+
put("generatedKW");
|
|
5806
|
+
put("exportKW");
|
|
5807
|
+
put("soc");
|
|
5808
|
+
put("chargeKW");
|
|
5809
|
+
put("dischargeKW");
|
|
5810
|
+
put("curtailable");
|
|
5811
|
+
put("minKW");
|
|
5812
|
+
put("position");
|
|
5813
|
+
if (d?.at) eq.measuredAt = d.at;
|
|
5814
|
+
this.revision++;
|
|
5815
|
+
}
|
|
5816
|
+
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
5817
|
+
unknownEquipmentReports = 0;
|
|
5761
5818
|
ingestMeasured(envelope) {
|
|
5762
5819
|
const d = envelope.data;
|
|
5763
5820
|
const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
|
|
@@ -5981,6 +6038,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5981
6038
|
closed: this.closed.map((w) => ({ ...w })),
|
|
5982
6039
|
closedTotal: this.closedTotal,
|
|
5983
6040
|
...this.peak ? { peakSince: { ...this.peak } } : {},
|
|
6041
|
+
...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
|
|
5984
6042
|
...contractKW !== void 0 ? { contractKW } : {},
|
|
5985
6043
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
5986
6044
|
...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
|
|
@@ -6043,6 +6101,90 @@ function ingestEnergyRecords(records, opts) {
|
|
|
6043
6101
|
}
|
|
6044
6102
|
return { accepted, rejected };
|
|
6045
6103
|
}
|
|
6104
|
+
var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"]);
|
|
6105
|
+
function isEnergyEquipmentRecord(record) {
|
|
6106
|
+
if (!record || typeof record !== "object") return false;
|
|
6107
|
+
const r = record;
|
|
6108
|
+
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0;
|
|
6109
|
+
}
|
|
6110
|
+
function ingestEnergyEquipmentRecords(records, opts) {
|
|
6111
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
6112
|
+
const accepted = [];
|
|
6113
|
+
const rejected = [];
|
|
6114
|
+
let seq = 0;
|
|
6115
|
+
for (const record of arr) {
|
|
6116
|
+
const errors = [];
|
|
6117
|
+
const r = record;
|
|
6118
|
+
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
6119
|
+
if (!equipmentId) errors.push("equipmentId \uC5C6\uC74C \u2014 \uC5B4\uB290 \uC124\uBE44\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uBAA8\uB974\uB294 \uAC12\uC740 \uC2E4\uC744 \uC218 \uC5C6\uB2E4");
|
|
6120
|
+
const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
|
|
6121
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
6122
|
+
if (!Number.isFinite(atMs)) errors.push("at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC9C0\uAE08 \uC2DC\uAC01\uC73C\uB85C \uBA54\uC6B0\uBA74 \uC5B8\uC81C\uC758 \uC0C1\uD0DC\uC778\uC9C0 \uC54C \uC218 \uC5C6\uB2E4");
|
|
6123
|
+
const num = (v, name, min, max) => {
|
|
6124
|
+
if (v === void 0 || v === null || v === "") return void 0;
|
|
6125
|
+
const n = Number(v);
|
|
6126
|
+
if (!Number.isFinite(n)) {
|
|
6127
|
+
errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
6128
|
+
return void 0;
|
|
6129
|
+
}
|
|
6130
|
+
if (min !== void 0 && n < min) {
|
|
6131
|
+
errors.push(`${name} \uAC00 ${min} \uBCF4\uB2E4 \uC791\uB2E4: ${n}`);
|
|
6132
|
+
return void 0;
|
|
6133
|
+
}
|
|
6134
|
+
if (max !== void 0 && n > max) {
|
|
6135
|
+
errors.push(`${name} \uAC00 ${max} \uBCF4\uB2E4 \uD06C\uB2E4: ${n}`);
|
|
6136
|
+
return void 0;
|
|
6137
|
+
}
|
|
6138
|
+
return n;
|
|
6139
|
+
};
|
|
6140
|
+
const generatedKW = num(r?.generatedKW, "generatedKW", 0);
|
|
6141
|
+
const exportKW = num(r?.exportKW, "exportKW");
|
|
6142
|
+
const soc = num(r?.soc, "soc", 0, 100);
|
|
6143
|
+
const chargeKW = num(r?.chargeKW, "chargeKW", 0);
|
|
6144
|
+
const dischargeKW = num(r?.dischargeKW, "dischargeKW", 0);
|
|
6145
|
+
const minKW = num(r?.minKW, "minKW", 0);
|
|
6146
|
+
let curtailable;
|
|
6147
|
+
if (r?.curtailable !== void 0 && r?.curtailable !== null) {
|
|
6148
|
+
if (typeof r.curtailable !== "boolean") errors.push(`curtailable \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(r.curtailable)}`);
|
|
6149
|
+
else curtailable = r.curtailable;
|
|
6150
|
+
}
|
|
6151
|
+
let position;
|
|
6152
|
+
if (r?.position !== void 0 && r?.position !== null && r?.position !== "") {
|
|
6153
|
+
const p = String(r.position);
|
|
6154
|
+
if (!POSITIONS.has(p)) errors.push(`position \uC774 \uD45C\uC900 \uAC12\uC774 \uC544\uB2C8\uB2E4(open\xB7closed\xB7intermediate\xB7bad): ${JSON.stringify(r.position)}`);
|
|
6155
|
+
else position = p;
|
|
6156
|
+
}
|
|
6157
|
+
const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
|
|
6158
|
+
if (!errors.length && Object.values(values).every((v) => v === void 0)) {
|
|
6159
|
+
errors.push("\uBC14\uAFC0 \uC0C1\uD0DC\uAC00 \uD558\uB098\uB3C4 \uC5C6\uB2E4 \u2014 \uAC12 \uC5C6\uB294 \uBCF4\uACE0\uB294 \uC0AC\uC2E4\uB85C \uC801\uC744 \uAC83\uC774 \uC5C6\uB2E4");
|
|
6160
|
+
}
|
|
6161
|
+
if (errors.length) {
|
|
6162
|
+
rejected.push({ record, errors });
|
|
6163
|
+
continue;
|
|
6164
|
+
}
|
|
6165
|
+
const eventTime = new Date(atMs).toISOString();
|
|
6166
|
+
const data = {
|
|
6167
|
+
equipmentId,
|
|
6168
|
+
at: eventTime,
|
|
6169
|
+
...generatedKW !== void 0 ? { generatedKW } : {},
|
|
6170
|
+
...exportKW !== void 0 ? { exportKW } : {},
|
|
6171
|
+
...soc !== void 0 ? { soc } : {},
|
|
6172
|
+
...chargeKW !== void 0 ? { chargeKW } : {},
|
|
6173
|
+
...dischargeKW !== void 0 ? { dischargeKW } : {},
|
|
6174
|
+
...curtailable !== void 0 ? { curtailable } : {},
|
|
6175
|
+
...minKW !== void 0 ? { minKW } : {},
|
|
6176
|
+
...position !== void 0 ? { position } : {}
|
|
6177
|
+
};
|
|
6178
|
+
accepted.push({
|
|
6179
|
+
eventId: `${opts.tenantId}-energy-eq-${++seq}`,
|
|
6180
|
+
eventType: ENERGY_EVENT.equipment,
|
|
6181
|
+
eventTime,
|
|
6182
|
+
tenantId: opts.tenantId,
|
|
6183
|
+
data
|
|
6184
|
+
});
|
|
6185
|
+
}
|
|
6186
|
+
return { accepted, rejected };
|
|
6187
|
+
}
|
|
6046
6188
|
|
|
6047
6189
|
// src/energy-attribution.ts
|
|
6048
6190
|
var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
|
@@ -6326,7 +6468,9 @@ function retiredVocabularyIn(line) {
|
|
|
6326
6468
|
inWorkCalendar,
|
|
6327
6469
|
inWorkCalendarAt,
|
|
6328
6470
|
ingest,
|
|
6471
|
+
ingestEnergyEquipmentRecords,
|
|
6329
6472
|
ingestEnergyRecords,
|
|
6473
|
+
isEnergyEquipmentRecord,
|
|
6330
6474
|
isEnergyRecord,
|
|
6331
6475
|
isEquipmentLevel,
|
|
6332
6476
|
isoDurationHours,
|
package/package.json
CHANGED