@operato/twin-kernel 0.7.4 → 0.7.6
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 +67 -5
- package/dist/energy-ingest.d.ts +23 -0
- package/dist/energy-ingest.js +103 -0
- package/dist/flow-engine.d.ts +23 -0
- package/dist/flow-engine.js +46 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist-cjs/index.cjs +187 -6
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -842,6 +842,18 @@ 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';
|
|
845
857
|
/**
|
|
846
858
|
* 유효 기간 밖이라 이 시각의 모델에 없다 — **네 번째 이유**(§Effectivity).
|
|
847
859
|
* 도입 예정(`not-yet`)과 폐기(`expired`)를 구별한다. 유효하면 값이 없다.
|
|
@@ -1218,7 +1230,13 @@ export interface EnergyState {
|
|
|
1218
1230
|
/** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
|
|
1219
1231
|
peakSince?: {
|
|
1220
1232
|
kW: number;
|
|
1221
|
-
windowStartMs: number;
|
|
1233
|
+
windowStartMs: number; /**
|
|
1234
|
+
* 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
|
|
1235
|
+
*
|
|
1236
|
+
* 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
|
|
1237
|
+
* 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
|
|
1238
|
+
*/
|
|
1239
|
+
unknownEquipment?: number;
|
|
1222
1240
|
};
|
|
1223
1241
|
/** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
|
|
1224
1242
|
contractKW?: number;
|
|
@@ -1428,6 +1446,12 @@ export declare const OP_EVENT: {
|
|
|
1428
1446
|
export declare const ENERGY_EVENT: {
|
|
1429
1447
|
/** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
|
|
1430
1448
|
readonly measured: "energy.measured";
|
|
1449
|
+
/**
|
|
1450
|
+
* 설비의 에너지 상태 — 발전·저장·감축 여지·개폐 위치.
|
|
1451
|
+
*
|
|
1452
|
+
* 계량과 다른 사건으로 둔다: 계량은 구간에 **누적**되고 이것은 그 설비의 **지금**을 바꾼다.
|
|
1453
|
+
*/
|
|
1454
|
+
readonly equipment: "energy.equipment";
|
|
1431
1455
|
/** 수요 구간 마감 — 그 구간의 최대 수요가 확정된다(요금의 단위). */
|
|
1432
1456
|
readonly demandWindow: "energy.demand.window";
|
|
1433
1457
|
/**
|
|
@@ -1455,6 +1479,27 @@ export declare const ENERGY_EVENT: {
|
|
|
1455
1479
|
readonly drSuggested: "energy.dr.suggested";
|
|
1456
1480
|
};
|
|
1457
1481
|
export type EnergyEventType = (typeof ENERGY_EVENT)[keyof typeof ENERGY_EVENT];
|
|
1482
|
+
/**
|
|
1483
|
+
* 설비가 낸 **자기 에너지 상태** — 계량이 아닌 능력들의 값.
|
|
1484
|
+
*
|
|
1485
|
+
* 계량(`energy.measured`)과 갈라 두는 이유: 계량은 **구간에 누적되는 표본**이고, 이것들은 **그 설비의
|
|
1486
|
+
* 지금 상태**다. 같은 문으로 넣으면 발전량이 부하 구간에 더해지거나 충전율이 요금 판정에 섞인다.
|
|
1487
|
+
*
|
|
1488
|
+
* 한 봉투에 여러 능력의 값이 함께 올 수 있다(원 시스템이 한 번에 준다) — 각 값은 그것을 선언한 능력의
|
|
1489
|
+
* 필드로만 들어간다. 없는 값은 **보내지 않는다**(0 을 보내면 「그렇게 측정됐다」가 된다).
|
|
1490
|
+
*/
|
|
1491
|
+
export interface EnergyEquipmentData {
|
|
1492
|
+
equipmentId: string;
|
|
1493
|
+
at: ISOTime;
|
|
1494
|
+
generatedKW?: number;
|
|
1495
|
+
exportKW?: number;
|
|
1496
|
+
soc?: number;
|
|
1497
|
+
chargeKW?: number;
|
|
1498
|
+
dischargeKW?: number;
|
|
1499
|
+
curtailable?: boolean;
|
|
1500
|
+
minKW?: number;
|
|
1501
|
+
position?: 'open' | 'closed' | 'intermediate' | 'bad';
|
|
1502
|
+
}
|
|
1458
1503
|
/** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
|
|
1459
1504
|
export interface EnergyMeasuredData {
|
|
1460
1505
|
/** 계량 지점(설비 id). */
|
|
@@ -1850,6 +1895,14 @@ export interface TwinKernel {
|
|
|
1850
1895
|
adoptStructure(def: TwinModelDef): StructureShift;
|
|
1851
1896
|
/** 이 커널이 관측으로 구동됨을 선언한다 — 첫 이벤트가 오기 전에도 그렇다. */
|
|
1852
1897
|
observe(): void;
|
|
1898
|
+
/**
|
|
1899
|
+
* 시각 기준점을 세운다 — 이 트윈의 「지금」은 `기준점 + 경과`다.
|
|
1900
|
+
*
|
|
1901
|
+
* 세우는 쪽이 기동 순간의 실제 시각을 준다. 주지 않으면 커널의 기본 기준점을 쓰는데, 그러면 시뮬
|
|
1902
|
+
* 트윈의 저널 시각이 재기동마다 되감겨 「언제 있었던 일인가」를 되짚을 수 없다.
|
|
1903
|
+
* **기동 직후에만** 부른다 — 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋난다.
|
|
1904
|
+
*/
|
|
1905
|
+
setClockOrigin?(originMs: number): void;
|
|
1853
1906
|
getSnapshot(): StateSnapshot;
|
|
1854
1907
|
onEvent(handler: EventHandler): Unsubscribe;
|
|
1855
1908
|
dispatch(cmd: Command): CommandAck;
|
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,49 @@ 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
|
+
this.revision++;
|
|
216
|
+
}
|
|
217
|
+
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
218
|
+
unknownEquipmentReports = 0;
|
|
178
219
|
ingestMeasured(envelope) {
|
|
179
220
|
const d = envelope.data;
|
|
180
221
|
/* 시각은 **계측이 말한 것**이 먼저다(`data.at`) — 봉투 시각은 전달 시각일 수 있다.
|
|
@@ -276,12 +317,23 @@ export class EmsKernel extends FlowEngine {
|
|
|
276
317
|
* kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
|
|
277
318
|
* 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
|
|
278
319
|
*/
|
|
279
|
-
...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {})
|
|
320
|
+
...(w.samples === 0 ? { observedAbsence: 'no-load-samples' } : {}),
|
|
321
|
+
/*
|
|
322
|
+
* **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
|
|
323
|
+
* (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
|
|
324
|
+
*/
|
|
325
|
+
...(w.derived ? { derived: true } : {})
|
|
280
326
|
});
|
|
281
327
|
/* 피크는 **마감된 구간**으로만 갱신한다 — 열린 구간의 최대는 아직 확정이 아니다. */
|
|
282
328
|
if (w.maxKW !== undefined && (this.peak === undefined || w.maxKW > this.peak.kW)) {
|
|
283
329
|
this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
|
|
284
|
-
this.emitOp(ENERGY_EVENT.peak, {
|
|
330
|
+
this.emitOp(ENERGY_EVENT.peak, {
|
|
331
|
+
kW: w.maxKW,
|
|
332
|
+
windowStartMs: w.startMs,
|
|
333
|
+
...(w.contractKW !== undefined ? { contractKW: w.contractKW } : {}),
|
|
334
|
+
/* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
|
|
335
|
+
...(w.derived ? { derived: true } : {})
|
|
336
|
+
});
|
|
285
337
|
}
|
|
286
338
|
/* 다음 구간은 표본이 올 때 연다 — 미리 열면 오지 않은 구간을 존재하는 것처럼 만든다. */
|
|
287
339
|
this.open = undefined;
|
|
@@ -419,9 +471,18 @@ export class EmsKernel extends FlowEngine {
|
|
|
419
471
|
}
|
|
420
472
|
tick(dtMs) {
|
|
421
473
|
super.tick(dtMs);
|
|
422
|
-
/*
|
|
423
|
-
|
|
424
|
-
|
|
474
|
+
/*
|
|
475
|
+
* 구간은 **이 트윈의 「지금」**으로 잡는다(`nowMs`) — 경과 시간(`clockMs`)이 아니다.
|
|
476
|
+
*
|
|
477
|
+
* 예전에는 경과 시간을 그대로 넘겨서 시뮬 트윈의 수요 구간이 **1970년**에 섰다. 계측 경로는
|
|
478
|
+
* 계측이 말한 절대 시각을 쓰므로, 두 경로가 서로 다른 시간축에 값을 넣고 있었던 셈이다.
|
|
479
|
+
* 요금은 벽시계의 15분으로 매겨지니 그 경계에 서야 견줄 수 있다.
|
|
480
|
+
*
|
|
481
|
+
* 부하를 먼저 만들고 마감한다 — 마감이 먼저면 마지막 값이 다음 구간으로 밀린다.
|
|
482
|
+
*/
|
|
483
|
+
const at = this.nowMs();
|
|
484
|
+
this.deriveLoad(at);
|
|
485
|
+
this.closeDue(at);
|
|
425
486
|
}
|
|
426
487
|
getSnapshot() {
|
|
427
488
|
const snap = super.getSnapshot();
|
|
@@ -439,6 +500,7 @@ export class EmsKernel extends FlowEngine {
|
|
|
439
500
|
closed: this.closed.map(w => ({ ...w })),
|
|
440
501
|
closedTotal: this.closedTotal,
|
|
441
502
|
...(this.peak ? { peakSince: { ...this.peak } } : {}),
|
|
503
|
+
...(this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {}),
|
|
442
504
|
...(contractKW !== undefined ? { contractKW } : {}),
|
|
443
505
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
444
506
|
...(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.d.ts
CHANGED
|
@@ -285,6 +285,20 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
285
285
|
orders: Map<string, FlowOrder>;
|
|
286
286
|
revision: number;
|
|
287
287
|
clockMs: number;
|
|
288
|
+
/**
|
|
289
|
+
* 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
|
|
290
|
+
*
|
|
291
|
+
* ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
|
|
292
|
+
* 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
|
|
293
|
+
* 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
|
|
294
|
+
* 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
|
|
295
|
+
*
|
|
296
|
+
* 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
|
|
297
|
+
* 가짜면 그 구간도 가짜 시각에 선다.
|
|
298
|
+
*
|
|
299
|
+
* 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
|
|
300
|
+
*/
|
|
301
|
+
protected originMs: number;
|
|
288
302
|
protected rng: Rng;
|
|
289
303
|
protected policy: AllocationPolicy;
|
|
290
304
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
@@ -452,6 +466,15 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
452
466
|
* 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
|
|
453
467
|
*/
|
|
454
468
|
protected nowMs(): number;
|
|
469
|
+
/**
|
|
470
|
+
* 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
|
|
471
|
+
*
|
|
472
|
+
* 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
|
|
473
|
+
* (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
|
|
474
|
+
*
|
|
475
|
+
* 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
|
|
476
|
+
*/
|
|
477
|
+
setClockOrigin(originMs: number): void;
|
|
455
478
|
protected now(): string;
|
|
456
479
|
/**
|
|
457
480
|
* 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
|
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']) {
|
|
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();
|
|
@@ -227,6 +244,20 @@ export class FlowEngine {
|
|
|
227
244
|
orders = new Map();
|
|
228
245
|
revision = 0;
|
|
229
246
|
clockMs = 0;
|
|
247
|
+
/**
|
|
248
|
+
* 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
|
|
249
|
+
*
|
|
250
|
+
* ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
|
|
251
|
+
* 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
|
|
252
|
+
* 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
|
|
253
|
+
* 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
|
|
254
|
+
*
|
|
255
|
+
* 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
|
|
256
|
+
* 가짜면 그 구간도 가짜 시각에 선다.
|
|
257
|
+
*
|
|
258
|
+
* 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
|
|
259
|
+
*/
|
|
260
|
+
originMs = BASE_EPOCH;
|
|
230
261
|
rng = mulberry32(1);
|
|
231
262
|
policy;
|
|
232
263
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
@@ -843,7 +874,7 @@ export class FlowEngine {
|
|
|
843
874
|
}),
|
|
844
875
|
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
845
876
|
equipment: [...this.equipment.values()].map(m => {
|
|
846
|
-
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) };
|
|
847
878
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
848
879
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
849
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 };
|
|
@@ -1016,7 +1047,20 @@ export class FlowEngine {
|
|
|
1016
1047
|
*/
|
|
1017
1048
|
nowMs() {
|
|
1018
1049
|
const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
|
|
1019
|
-
return observed ??
|
|
1050
|
+
return observed ?? this.originMs + this.clockMs;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
|
|
1054
|
+
*
|
|
1055
|
+
* 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
|
|
1056
|
+
* (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
|
|
1057
|
+
*
|
|
1058
|
+
* 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
|
|
1059
|
+
*/
|
|
1060
|
+
setClockOrigin(originMs) {
|
|
1061
|
+
if (!Number.isFinite(originMs))
|
|
1062
|
+
throw new Error('clock origin must be a finite epoch millisecond value');
|
|
1063
|
+
this.originMs = originMs;
|
|
1020
1064
|
}
|
|
1021
1065
|
now() { return new Date(this.nowMs()).toISOString(); }
|
|
1022
1066
|
/**
|
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"]) {
|
|
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();
|
|
@@ -2809,6 +2825,20 @@ var FlowEngine = class {
|
|
|
2809
2825
|
orders = /* @__PURE__ */ new Map();
|
|
2810
2826
|
revision = 0;
|
|
2811
2827
|
clockMs = 0;
|
|
2828
|
+
/**
|
|
2829
|
+
* 시각의 **기준점** — 이 트윈의 「지금」은 `originMs + clockMs` 다.
|
|
2830
|
+
*
|
|
2831
|
+
* ── 왜 고정 상수가 아닌가 (2026-08-15) ─────────────────────────────────────
|
|
2832
|
+
* 기준점이 `BASE_EPOCH` 하나로 고정돼 있어서, 시뮬 트윈의 저널 시각이 **재기동마다 되감겼다.**
|
|
2833
|
+
* 며칠을 돈 트윈의 사건들이 전부 `2026-01-01T00:00:xx` 에 몰려 있었고, 그래서 「언제 있었던 일인가」를
|
|
2834
|
+
* 되짚을 수 없었다. 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다.
|
|
2835
|
+
*
|
|
2836
|
+
* 에너지에서는 더 아프다: 수요 구간은 벽시계의 15분에 맞춰 끊어야 요금과 견줄 수 있는데, 기준점이
|
|
2837
|
+
* 가짜면 그 구간도 가짜 시각에 선다.
|
|
2838
|
+
*
|
|
2839
|
+
* 기본값은 그대로 둔다(시험·결정성). 살아 있는 시뮬 트윈을 세우는 호스트가 실제 시각으로 옮긴다.
|
|
2840
|
+
*/
|
|
2841
|
+
originMs = BASE_EPOCH;
|
|
2812
2842
|
rng = mulberry32(1);
|
|
2813
2843
|
policy;
|
|
2814
2844
|
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
@@ -3375,7 +3405,7 @@ var FlowEngine = class {
|
|
|
3375
3405
|
}),
|
|
3376
3406
|
items: [...this.items.values()].map((i) => this.itemState(i)),
|
|
3377
3407
|
equipment: [...this.equipment.values()].map((m) => {
|
|
3378
|
-
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) };
|
|
3379
3409
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
3380
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 };
|
|
3381
3411
|
return s;
|
|
@@ -3529,7 +3559,19 @@ var FlowEngine = class {
|
|
|
3529
3559
|
*/
|
|
3530
3560
|
nowMs() {
|
|
3531
3561
|
const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
|
|
3532
|
-
return observed ??
|
|
3562
|
+
return observed ?? this.originMs + this.clockMs;
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* 시각 기준점을 세운다 — **살아 있는 시뮬 트윈은 실제 시각 위에서 돈다.**
|
|
3566
|
+
*
|
|
3567
|
+
* 세우는 쪽(호스트)이 기동 순간의 실제 시각을 준다. 재기동하면 그만큼 앞으로 뛰는데, 그것이 사실이다
|
|
3568
|
+
* (그 사이 이 트윈은 돌지 않았고, 저널의 빈 구간이 그 사실을 말한다).
|
|
3569
|
+
*
|
|
3570
|
+
* 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
|
|
3571
|
+
*/
|
|
3572
|
+
setClockOrigin(originMs) {
|
|
3573
|
+
if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
|
|
3574
|
+
this.originMs = originMs;
|
|
3533
3575
|
}
|
|
3534
3576
|
now() {
|
|
3535
3577
|
return new Date(this.nowMs()).toISOString();
|
|
@@ -5730,8 +5772,48 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5730
5772
|
this.ingestMeasured(envelope);
|
|
5731
5773
|
return;
|
|
5732
5774
|
}
|
|
5775
|
+
if (envelope.eventType === ENERGY_EVENT.equipment) {
|
|
5776
|
+
this.applyEquipmentEnergy(envelope);
|
|
5777
|
+
return;
|
|
5778
|
+
}
|
|
5733
5779
|
super.apply(envelope);
|
|
5734
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
|
+
this.revision++;
|
|
5814
|
+
}
|
|
5815
|
+
/** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
|
|
5816
|
+
unknownEquipmentReports = 0;
|
|
5735
5817
|
ingestMeasured(envelope) {
|
|
5736
5818
|
const d = envelope.data;
|
|
5737
5819
|
const atMs = [String(d?.at ?? ""), String(envelope.eventTime ?? "")].map((v) => Date.parse(v)).find((v) => Number.isFinite(v));
|
|
@@ -5802,11 +5884,22 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5802
5884
|
* kW 를 못 읽은 경우(계기 오류·필드 누락)가 실제로 있고, 그것은 「아무것도 오지 않았다」와
|
|
5803
5885
|
* 다르다. 무엇이 왔는지는 지점별 `samplesInWindow` 가 답한다.
|
|
5804
5886
|
*/
|
|
5805
|
-
...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {}
|
|
5887
|
+
...w.samples === 0 ? { observedAbsence: "no-load-samples" } : {},
|
|
5888
|
+
/*
|
|
5889
|
+
* **만든 값이면 그렇게 말한다.** 상태에만 표시하고 사실에는 빠뜨리면, 저널을 읽는 쪽
|
|
5890
|
+
* (성과·이력·보고서)이 시뮬레이션의 수를 계측으로 읽는다 — 값이 그럴듯할수록 위험하다.
|
|
5891
|
+
*/
|
|
5892
|
+
...w.derived ? { derived: true } : {}
|
|
5806
5893
|
});
|
|
5807
5894
|
if (w.maxKW !== void 0 && (this.peak === void 0 || w.maxKW > this.peak.kW)) {
|
|
5808
5895
|
this.peak = { kW: w.maxKW, windowStartMs: w.startMs };
|
|
5809
|
-
this.emitOp(ENERGY_EVENT.peak, {
|
|
5896
|
+
this.emitOp(ENERGY_EVENT.peak, {
|
|
5897
|
+
kW: w.maxKW,
|
|
5898
|
+
windowStartMs: w.startMs,
|
|
5899
|
+
...w.contractKW !== void 0 ? { contractKW: w.contractKW } : {},
|
|
5900
|
+
/* 피크도 그 구간에서 왔다 — 구간이 만든 값이면 피크도 만든 값이다. */
|
|
5901
|
+
...w.derived ? { derived: true } : {}
|
|
5902
|
+
});
|
|
5810
5903
|
}
|
|
5811
5904
|
this.open = void 0;
|
|
5812
5905
|
}
|
|
@@ -5926,8 +6019,9 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5926
6019
|
}
|
|
5927
6020
|
tick(dtMs) {
|
|
5928
6021
|
super.tick(dtMs);
|
|
5929
|
-
this.
|
|
5930
|
-
this.
|
|
6022
|
+
const at = this.nowMs();
|
|
6023
|
+
this.deriveLoad(at);
|
|
6024
|
+
this.closeDue(at);
|
|
5931
6025
|
}
|
|
5932
6026
|
getSnapshot() {
|
|
5933
6027
|
const snap = super.getSnapshot();
|
|
@@ -5943,6 +6037,7 @@ var EmsKernel = class extends FlowEngine {
|
|
|
5943
6037
|
closed: this.closed.map((w) => ({ ...w })),
|
|
5944
6038
|
closedTotal: this.closedTotal,
|
|
5945
6039
|
...this.peak ? { peakSince: { ...this.peak } } : {},
|
|
6040
|
+
...this.unknownEquipmentReports ? { unknownEquipment: this.unknownEquipmentReports } : {},
|
|
5946
6041
|
...contractKW !== void 0 ? { contractKW } : {},
|
|
5947
6042
|
/* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
|
|
5948
6043
|
...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
|
|
@@ -6005,6 +6100,90 @@ function ingestEnergyRecords(records, opts) {
|
|
|
6005
6100
|
}
|
|
6006
6101
|
return { accepted, rejected };
|
|
6007
6102
|
}
|
|
6103
|
+
var POSITIONS = /* @__PURE__ */ new Set(["open", "closed", "intermediate", "bad"]);
|
|
6104
|
+
function isEnergyEquipmentRecord(record) {
|
|
6105
|
+
if (!record || typeof record !== "object") return false;
|
|
6106
|
+
const r = record;
|
|
6107
|
+
return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.epc === void 0 && r.meterId === void 0;
|
|
6108
|
+
}
|
|
6109
|
+
function ingestEnergyEquipmentRecords(records, opts) {
|
|
6110
|
+
const arr = Array.isArray(records) ? records : records ? [records] : [];
|
|
6111
|
+
const accepted = [];
|
|
6112
|
+
const rejected = [];
|
|
6113
|
+
let seq = 0;
|
|
6114
|
+
for (const record of arr) {
|
|
6115
|
+
const errors = [];
|
|
6116
|
+
const r = record;
|
|
6117
|
+
const equipmentId = String(r?.equipmentId ?? "").trim();
|
|
6118
|
+
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");
|
|
6119
|
+
const at = String(r?.at ?? "").trim() || opts.defaultEventTime;
|
|
6120
|
+
const atMs = at ? Date.parse(at) : Number.NaN;
|
|
6121
|
+
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");
|
|
6122
|
+
const num = (v, name, min, max) => {
|
|
6123
|
+
if (v === void 0 || v === null || v === "") return void 0;
|
|
6124
|
+
const n = Number(v);
|
|
6125
|
+
if (!Number.isFinite(n)) {
|
|
6126
|
+
errors.push(`${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
|
|
6127
|
+
return void 0;
|
|
6128
|
+
}
|
|
6129
|
+
if (min !== void 0 && n < min) {
|
|
6130
|
+
errors.push(`${name} \uAC00 ${min} \uBCF4\uB2E4 \uC791\uB2E4: ${n}`);
|
|
6131
|
+
return void 0;
|
|
6132
|
+
}
|
|
6133
|
+
if (max !== void 0 && n > max) {
|
|
6134
|
+
errors.push(`${name} \uAC00 ${max} \uBCF4\uB2E4 \uD06C\uB2E4: ${n}`);
|
|
6135
|
+
return void 0;
|
|
6136
|
+
}
|
|
6137
|
+
return n;
|
|
6138
|
+
};
|
|
6139
|
+
const generatedKW = num(r?.generatedKW, "generatedKW", 0);
|
|
6140
|
+
const exportKW = num(r?.exportKW, "exportKW");
|
|
6141
|
+
const soc = num(r?.soc, "soc", 0, 100);
|
|
6142
|
+
const chargeKW = num(r?.chargeKW, "chargeKW", 0);
|
|
6143
|
+
const dischargeKW = num(r?.dischargeKW, "dischargeKW", 0);
|
|
6144
|
+
const minKW = num(r?.minKW, "minKW", 0);
|
|
6145
|
+
let curtailable;
|
|
6146
|
+
if (r?.curtailable !== void 0 && r?.curtailable !== null) {
|
|
6147
|
+
if (typeof r.curtailable !== "boolean") errors.push(`curtailable \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(r.curtailable)}`);
|
|
6148
|
+
else curtailable = r.curtailable;
|
|
6149
|
+
}
|
|
6150
|
+
let position;
|
|
6151
|
+
if (r?.position !== void 0 && r?.position !== null && r?.position !== "") {
|
|
6152
|
+
const p = String(r.position);
|
|
6153
|
+
if (!POSITIONS.has(p)) errors.push(`position \uC774 \uD45C\uC900 \uAC12\uC774 \uC544\uB2C8\uB2E4(open\xB7closed\xB7intermediate\xB7bad): ${JSON.stringify(r.position)}`);
|
|
6154
|
+
else position = p;
|
|
6155
|
+
}
|
|
6156
|
+
const values = { generatedKW, exportKW, soc, chargeKW, dischargeKW, curtailable, minKW, position };
|
|
6157
|
+
if (!errors.length && Object.values(values).every((v) => v === void 0)) {
|
|
6158
|
+
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");
|
|
6159
|
+
}
|
|
6160
|
+
if (errors.length) {
|
|
6161
|
+
rejected.push({ record, errors });
|
|
6162
|
+
continue;
|
|
6163
|
+
}
|
|
6164
|
+
const eventTime = new Date(atMs).toISOString();
|
|
6165
|
+
const data = {
|
|
6166
|
+
equipmentId,
|
|
6167
|
+
at: eventTime,
|
|
6168
|
+
...generatedKW !== void 0 ? { generatedKW } : {},
|
|
6169
|
+
...exportKW !== void 0 ? { exportKW } : {},
|
|
6170
|
+
...soc !== void 0 ? { soc } : {},
|
|
6171
|
+
...chargeKW !== void 0 ? { chargeKW } : {},
|
|
6172
|
+
...dischargeKW !== void 0 ? { dischargeKW } : {},
|
|
6173
|
+
...curtailable !== void 0 ? { curtailable } : {},
|
|
6174
|
+
...minKW !== void 0 ? { minKW } : {},
|
|
6175
|
+
...position !== void 0 ? { position } : {}
|
|
6176
|
+
};
|
|
6177
|
+
accepted.push({
|
|
6178
|
+
eventId: `${opts.tenantId}-energy-eq-${++seq}`,
|
|
6179
|
+
eventType: ENERGY_EVENT.equipment,
|
|
6180
|
+
eventTime,
|
|
6181
|
+
tenantId: opts.tenantId,
|
|
6182
|
+
data
|
|
6183
|
+
});
|
|
6184
|
+
}
|
|
6185
|
+
return { accepted, rejected };
|
|
6186
|
+
}
|
|
6008
6187
|
|
|
6009
6188
|
// src/energy-attribution.ts
|
|
6010
6189
|
var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
|
@@ -6288,7 +6467,9 @@ function retiredVocabularyIn(line) {
|
|
|
6288
6467
|
inWorkCalendar,
|
|
6289
6468
|
inWorkCalendarAt,
|
|
6290
6469
|
ingest,
|
|
6470
|
+
ingestEnergyEquipmentRecords,
|
|
6291
6471
|
ingestEnergyRecords,
|
|
6472
|
+
isEnergyEquipmentRecord,
|
|
6292
6473
|
isEnergyRecord,
|
|
6293
6474
|
isEquipmentLevel,
|
|
6294
6475
|
isoDurationHours,
|
package/package.json
CHANGED