@operato/twin-kernel 0.7.74 → 0.7.75

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.
@@ -2035,6 +2035,18 @@ export interface EnergyState {
2035
2035
  * 틀렸다는 뜻이고, 사람이 찾아내지 않아도 드러나야 한다.
2036
2036
  */
2037
2037
  lastBill?: EnergyBillData;
2038
+ /**
2039
+ * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
2040
+ *
2041
+ * `periodStart` 는 마지막 청구서의 끝이다(그 뒤가 아직 청구되지 않은 구간). 그것이 바뀌면 처음부터
2042
+ * 다시 센다 — 지난 주기의 최고가 이번 주기를 정하지 않는다.
2043
+ */
2044
+ demandHigh?: {
2045
+ kW: number;
2046
+ atTo: ISOTime;
2047
+ periodStart?: ISOTime;
2048
+ since: ISOTime;
2049
+ };
2038
2050
  /**
2039
2051
  * 설비마다 **지금 기간의 발전 관측** — 기간 발전량을 확정할 때 쓰는 기준점.
2040
2052
  *
@@ -199,6 +199,13 @@ export declare class EmsKernel extends FlowEngine {
199
199
  private unclosedGenerationPeriods;
200
200
  /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
201
201
  private lastBill?;
202
+ /**
203
+ * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
204
+ *
205
+ * `since` 는 이 주기에서 처음 잰 구간의 끝이다. `periodStart` 는 마지막 청구서의 끝이고,
206
+ * 그것이 바뀌면 처음부터 다시 센다.
207
+ */
208
+ private demandHigh?;
202
209
  private applyEquipmentEnergy;
203
210
  /** 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — 조용히 버리지 않는다. */
204
211
  private unknownEquipmentReports;
@@ -242,6 +249,31 @@ export declare class EmsKernel extends FlowEngine {
242
249
  * 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
243
250
  */
244
251
  protected collectAttentions(): Attention[];
252
+ /**
253
+ * **이 주기의 최고가 요금적용전력을 넘었다** — 다음 주기의 기본요금이 오른다.
254
+ *
255
+ * ── 왜 계약 초과와 다른 신호인가 (2026-08-29) ────────────────────────────
256
+ * 계약 초과는 약정 위반이고 지금 조치할 일이다(줄이면 된다). 이쪽은 **이미 일어난 일**이다 —
257
+ * 넘은 순간 다음 주기의 기준이 정해지고, 지금 줄여도 되돌아가지 않는다. 사람이 할 일이 다르므로
258
+ * 신호도 다르다.
259
+ *
260
+ * ── 한 번만 울린다 ──────────────────────────────────────────────────────────
261
+ * 넘으면 그 주기 내내 참이다. 구간마다 다시 울리면 소음이 되고, 소음이 되면 아무도 안 본다.
262
+ * 그래서 신호의 id 를 주기로 묶고 「처음 넘은 시각」이 아니라 **그 주기의 최고**를 싣는다.
263
+ *
264
+ * ── 늦게 안다 ───────────────────────────────────────────────────────────────
265
+ * 최고 수요는 지난 구간이 마감되어야 온다(지금 원본은 시간마다). 그래서 넘은 것을 최대 한 시간
266
+ * 뒤에 안다. 그 사실을 `observedTo` 로 함께 내어 화면이 「언제까지의 값인가」를 말할 수 있게 한다.
267
+ */
268
+ protected billingDemandAttention(): Attention | undefined;
269
+ /**
270
+ * 선언된 **요금적용전력** — 기본요금이 걸리는 기준(§`EMS_PROPERTY.billingDemandKW`).
271
+ *
272
+ * 청구서가 그 값을 실어 오면 그쪽이 먼저다(§`billingDemandKW()`). 이것은 청구가 오지 않는 현장의 몫이다.
273
+ */
274
+ private declaredBillingDemandKW;
275
+ /** 지금 쓰는 요금적용전력 — **청구서가 먼저다.** 매달 바뀌는 값이라 선언은 곧 낡는다. */
276
+ private billingDemandKW;
245
277
  /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
246
278
  private incomingLocationId;
247
279
  /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
@@ -315,6 +315,26 @@ export class EmsKernel extends FlowEngine {
315
315
  point.lastPeriodTo = d.to;
316
316
  if (d?.basis)
317
317
  point.lastPeriodBasis = d.basis;
318
+ /*
319
+ * ── 이 청구 주기의 최고 수요 (2026-08-29) ─────────────────────────────────
320
+ *
321
+ * 기본요금은 이 값으로 다시 정해진다. 그래서 **넘은 순간과 얼마나 넘었는지**를 기억한다.
322
+ *
323
+ * 주기의 시작은 마지막 청구서의 끝이다(그 뒤가 아직 청구되지 않은 구간이다). 청구서가 없으면
324
+ * 관측을 시작한 때부터다 — 그 사실은 `since` 가 말한다.
325
+ *
326
+ * 구간마다 새로 세지 않는다: 한 번 넘으면 그 주기 내내 참이고, 매 구간 다시 알리면 소음이 된다.
327
+ */
328
+ const maxKW = Number(d?.maxKW);
329
+ if (Number.isFinite(maxKW) && maxKW >= 0 && d?.to) {
330
+ const periodStart = this.lastBill?.to;
331
+ /* 청구 주기가 바뀌었으면 처음부터 다시 센다 — 지난 주기의 최고가 이번 주기를 정하지 않는다. */
332
+ if (this.demandHigh && periodStart && this.demandHigh.periodStart !== periodStart)
333
+ this.demandHigh = undefined;
334
+ if (!this.demandHigh || maxKW > this.demandHigh.kW) {
335
+ this.demandHigh = { kW: maxKW, atTo: String(d.to), periodStart, since: this.demandHigh?.since ?? String(d.to) };
336
+ }
337
+ }
318
338
  this.revision++;
319
339
  }
320
340
  /**
@@ -582,6 +602,13 @@ export class EmsKernel extends FlowEngine {
582
602
  unclosedGenerationPeriods = 0;
583
603
  /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
584
604
  lastBill;
605
+ /**
606
+ * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
607
+ *
608
+ * `since` 는 이 주기에서 처음 잰 구간의 끝이다. `periodStart` 는 마지막 청구서의 끝이고,
609
+ * 그것이 바뀌면 처음부터 다시 센다.
610
+ */
611
+ demandHigh;
585
612
  applyEquipmentEnergy(envelope) {
586
613
  const d = envelope.data;
587
614
  const id = String(d?.equipmentId ?? '').trim();
@@ -945,6 +972,9 @@ export class EmsKernel extends FlowEngine {
945
972
  */
946
973
  collectAttentions() {
947
974
  const out = super.collectAttentions();
975
+ const billingOver = this.billingDemandAttention();
976
+ if (billingOver)
977
+ out.push(billingOver);
948
978
  const w = this.open;
949
979
  if (!w || w.contractKW === undefined || w.meanKW === undefined)
950
980
  return out;
@@ -988,6 +1018,77 @@ export class EmsKernel extends FlowEngine {
988
1018
  });
989
1019
  return out;
990
1020
  }
1021
+ /**
1022
+ * **이 주기의 최고가 요금적용전력을 넘었다** — 다음 주기의 기본요금이 오른다.
1023
+ *
1024
+ * ── 왜 계약 초과와 다른 신호인가 (2026-08-29) ────────────────────────────
1025
+ * 계약 초과는 약정 위반이고 지금 조치할 일이다(줄이면 된다). 이쪽은 **이미 일어난 일**이다 —
1026
+ * 넘은 순간 다음 주기의 기준이 정해지고, 지금 줄여도 되돌아가지 않는다. 사람이 할 일이 다르므로
1027
+ * 신호도 다르다.
1028
+ *
1029
+ * ── 한 번만 울린다 ──────────────────────────────────────────────────────────
1030
+ * 넘으면 그 주기 내내 참이다. 구간마다 다시 울리면 소음이 되고, 소음이 되면 아무도 안 본다.
1031
+ * 그래서 신호의 id 를 주기로 묶고 「처음 넘은 시각」이 아니라 **그 주기의 최고**를 싣는다.
1032
+ *
1033
+ * ── 늦게 안다 ───────────────────────────────────────────────────────────────
1034
+ * 최고 수요는 지난 구간이 마감되어야 온다(지금 원본은 시간마다). 그래서 넘은 것을 최대 한 시간
1035
+ * 뒤에 안다. 그 사실을 `observedTo` 로 함께 내어 화면이 「언제까지의 값인가」를 말할 수 있게 한다.
1036
+ */
1037
+ billingDemandAttention() {
1038
+ const high = this.demandHigh;
1039
+ const basis = this.billingDemandKW();
1040
+ if (!high || basis === undefined || high.kW <= basis)
1041
+ return undefined;
1042
+ const over = high.kW - basis;
1043
+ const ratio = over / basis;
1044
+ return {
1045
+ id: `billing-demand-exceeded:${high.periodStart ?? high.since}`,
1046
+ kind: 'billing-demand-exceeded',
1047
+ severity: ratio > 0.15 ? 'high' : 'medium',
1048
+ anchor: { locationId: this.incomingLocationId() },
1049
+ params: {
1050
+ peakKW: Math.round(high.kW),
1051
+ billingDemandKW: Math.round(basis),
1052
+ overKW: Math.round(over),
1053
+ /* 값의 출처 — 청구서가 말한 기준인지 현장의 선언인지. 선언은 곧 낡는다. */
1054
+ basisFrom: Number.isFinite(Number(this.lastBill?.billingDemandKW)) ? 'bill' : 'declaration',
1055
+ observedTo: high.atTo,
1056
+ periodSince: high.since
1057
+ },
1058
+ /*
1059
+ * 조치를 걸지 않는다. **이미 일어난 일이라 되돌릴 수 없다** — 지금 줄여도 이 주기의 최고는
1060
+ * 그대로다. 조치를 붙이면 화면이 「누르면 해결된다」고 거짓말한다.
1061
+ */
1062
+ recommendedActions: []
1063
+ };
1064
+ }
1065
+ /**
1066
+ * 선언된 **요금적용전력** — 기본요금이 걸리는 기준(§`EMS_PROPERTY.billingDemandKW`).
1067
+ *
1068
+ * 청구서가 그 값을 실어 오면 그쪽이 먼저다(§`billingDemandKW()`). 이것은 청구가 오지 않는 현장의 몫이다.
1069
+ */
1070
+ declaredBillingDemandKW() {
1071
+ let max;
1072
+ for (const loc of this.boardDef?.locations ?? []) {
1073
+ for (const p of loc.properties ?? []) {
1074
+ if (p.id !== EMS_PROPERTY.billingDemandKW)
1075
+ continue;
1076
+ const v = Number(p.value);
1077
+ if (!Number.isFinite(v) || v <= 0)
1078
+ continue;
1079
+ if (max === undefined || v > max)
1080
+ max = v;
1081
+ }
1082
+ }
1083
+ return max;
1084
+ }
1085
+ /** 지금 쓰는 요금적용전력 — **청구서가 먼저다.** 매달 바뀌는 값이라 선언은 곧 낡는다. */
1086
+ billingDemandKW() {
1087
+ const billed = Number(this.lastBill?.billingDemandKW);
1088
+ if (Number.isFinite(billed) && billed > 0)
1089
+ return billed;
1090
+ return this.declaredBillingDemandKW();
1091
+ }
991
1092
  /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
992
1093
  incomingLocationId() {
993
1094
  for (const loc of this.boardDef?.locations ?? []) {
@@ -1381,6 +1482,9 @@ export class EmsKernel extends FlowEngine {
1381
1482
  /* 청구서도 이어받는다 — 재기동마다 사라지면 우리 계산과 견줄 기준이 없어진다. */
1382
1483
  if (e.lastBill?.from && e.lastBill?.to)
1383
1484
  this.lastBill = { ...e.lastBill };
1485
+ /* 이 주기의 최고도 이어받는다 — 재기동마다 0 으로 돌아가면 이미 넘은 것을 못 넘었다고 말한다. */
1486
+ if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since)
1487
+ this.demandHigh = { ...e.demandHigh };
1384
1488
  }
1385
1489
  getSnapshot() {
1386
1490
  const snap = super.getSnapshot();
@@ -1406,6 +1510,7 @@ export class EmsKernel extends FlowEngine {
1406
1510
  : {}),
1407
1511
  ...(this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {}),
1408
1512
  ...(this.lastBill ? { lastBill: { ...this.lastBill } } : {}),
1513
+ ...(this.demandHigh ? { demandHigh: { ...this.demandHigh } } : {}),
1409
1514
  ...(contractKW !== undefined ? { contractKW } : {}),
1410
1515
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
1411
1516
  ...(this.flowRequests.size
@@ -7980,6 +7980,14 @@ var EmsKernel = class extends FlowEngine {
7980
7980
  if (d?.from) point.lastPeriodFrom = d.from;
7981
7981
  if (d?.to) point.lastPeriodTo = d.to;
7982
7982
  if (d?.basis) point.lastPeriodBasis = d.basis;
7983
+ const maxKW = Number(d?.maxKW);
7984
+ if (Number.isFinite(maxKW) && maxKW >= 0 && d?.to) {
7985
+ const periodStart = this.lastBill?.to;
7986
+ if (this.demandHigh && periodStart && this.demandHigh.periodStart !== periodStart) this.demandHigh = void 0;
7987
+ if (!this.demandHigh || maxKW > this.demandHigh.kW) {
7988
+ this.demandHigh = { kW: maxKW, atTo: String(d.to), periodStart, since: this.demandHigh?.since ?? String(d.to) };
7989
+ }
7990
+ }
7983
7991
  this.revision++;
7984
7992
  }
7985
7993
  /**
@@ -8158,6 +8166,13 @@ var EmsKernel = class extends FlowEngine {
8158
8166
  unclosedGenerationPeriods = 0;
8159
8167
  /** 마지막으로 받은 청구서 — 공급자가 확정한 금액. 우리 계산과 견주는 기준이다. */
8160
8168
  lastBill;
8169
+ /**
8170
+ * 이 청구 주기의 **최고 수요** — 기본요금이 이 값으로 다시 정해진다.
8171
+ *
8172
+ * `since` 는 이 주기에서 처음 잰 구간의 끝이다. `periodStart` 는 마지막 청구서의 끝이고,
8173
+ * 그것이 바뀌면 처음부터 다시 센다.
8174
+ */
8175
+ demandHigh;
8161
8176
  applyEquipmentEnergy(envelope) {
8162
8177
  const d = envelope.data;
8163
8178
  const id = String(d?.equipmentId ?? "").trim();
@@ -8397,6 +8412,8 @@ var EmsKernel = class extends FlowEngine {
8397
8412
  */
8398
8413
  collectAttentions() {
8399
8414
  const out = super.collectAttentions();
8415
+ const billingOver = this.billingDemandAttention();
8416
+ if (billingOver) out.push(billingOver);
8400
8417
  const w = this.open;
8401
8418
  if (!w || w.contractKW === void 0 || w.meanKW === void 0) return out;
8402
8419
  const projected = w.meanKW;
@@ -8435,6 +8452,72 @@ var EmsKernel = class extends FlowEngine {
8435
8452
  });
8436
8453
  return out;
8437
8454
  }
8455
+ /**
8456
+ * **이 주기의 최고가 요금적용전력을 넘었다** — 다음 주기의 기본요금이 오른다.
8457
+ *
8458
+ * ── 왜 계약 초과와 다른 신호인가 (2026-08-29) ────────────────────────────
8459
+ * 계약 초과는 약정 위반이고 지금 조치할 일이다(줄이면 된다). 이쪽은 **이미 일어난 일**이다 —
8460
+ * 넘은 순간 다음 주기의 기준이 정해지고, 지금 줄여도 되돌아가지 않는다. 사람이 할 일이 다르므로
8461
+ * 신호도 다르다.
8462
+ *
8463
+ * ── 한 번만 울린다 ──────────────────────────────────────────────────────────
8464
+ * 넘으면 그 주기 내내 참이다. 구간마다 다시 울리면 소음이 되고, 소음이 되면 아무도 안 본다.
8465
+ * 그래서 신호의 id 를 주기로 묶고 「처음 넘은 시각」이 아니라 **그 주기의 최고**를 싣는다.
8466
+ *
8467
+ * ── 늦게 안다 ───────────────────────────────────────────────────────────────
8468
+ * 최고 수요는 지난 구간이 마감되어야 온다(지금 원본은 시간마다). 그래서 넘은 것을 최대 한 시간
8469
+ * 뒤에 안다. 그 사실을 `observedTo` 로 함께 내어 화면이 「언제까지의 값인가」를 말할 수 있게 한다.
8470
+ */
8471
+ billingDemandAttention() {
8472
+ const high = this.demandHigh;
8473
+ const basis = this.billingDemandKW();
8474
+ if (!high || basis === void 0 || high.kW <= basis) return void 0;
8475
+ const over = high.kW - basis;
8476
+ const ratio = over / basis;
8477
+ return {
8478
+ id: `billing-demand-exceeded:${high.periodStart ?? high.since}`,
8479
+ kind: "billing-demand-exceeded",
8480
+ severity: ratio > 0.15 ? "high" : "medium",
8481
+ anchor: { locationId: this.incomingLocationId() },
8482
+ params: {
8483
+ peakKW: Math.round(high.kW),
8484
+ billingDemandKW: Math.round(basis),
8485
+ overKW: Math.round(over),
8486
+ /* 값의 출처 — 청구서가 말한 기준인지 현장의 선언인지. 선언은 곧 낡는다. */
8487
+ basisFrom: Number.isFinite(Number(this.lastBill?.billingDemandKW)) ? "bill" : "declaration",
8488
+ observedTo: high.atTo,
8489
+ periodSince: high.since
8490
+ },
8491
+ /*
8492
+ * 조치를 걸지 않는다. **이미 일어난 일이라 되돌릴 수 없다** — 지금 줄여도 이 주기의 최고는
8493
+ * 그대로다. 조치를 붙이면 화면이 「누르면 해결된다」고 거짓말한다.
8494
+ */
8495
+ recommendedActions: []
8496
+ };
8497
+ }
8498
+ /**
8499
+ * 선언된 **요금적용전력** — 기본요금이 걸리는 기준(§`EMS_PROPERTY.billingDemandKW`).
8500
+ *
8501
+ * 청구서가 그 값을 실어 오면 그쪽이 먼저다(§`billingDemandKW()`). 이것은 청구가 오지 않는 현장의 몫이다.
8502
+ */
8503
+ declaredBillingDemandKW() {
8504
+ let max;
8505
+ for (const loc of this.boardDef?.locations ?? []) {
8506
+ for (const p of loc.properties ?? []) {
8507
+ if (p.id !== EMS_PROPERTY.billingDemandKW) continue;
8508
+ const v = Number(p.value);
8509
+ if (!Number.isFinite(v) || v <= 0) continue;
8510
+ if (max === void 0 || v > max) max = v;
8511
+ }
8512
+ }
8513
+ return max;
8514
+ }
8515
+ /** 지금 쓰는 요금적용전력 — **청구서가 먼저다.** 매달 바뀌는 값이라 선언은 곧 낡는다. */
8516
+ billingDemandKW() {
8517
+ const billed = Number(this.lastBill?.billingDemandKW);
8518
+ if (Number.isFinite(billed) && billed > 0) return billed;
8519
+ return this.declaredBillingDemandKW();
8520
+ }
8438
8521
  /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
8439
8522
  incomingLocationId() {
8440
8523
  for (const loc of this.boardDef?.locations ?? []) {
@@ -8715,6 +8798,7 @@ var EmsKernel = class extends FlowEngine {
8715
8798
  }
8716
8799
  if (Number.isFinite(e.unclosedGenerationPeriods)) this.unclosedGenerationPeriods = Number(e.unclosedGenerationPeriods);
8717
8800
  if (e.lastBill?.from && e.lastBill?.to) this.lastBill = { ...e.lastBill };
8801
+ if (Number.isFinite(Number(e.demandHigh?.kW)) && e.demandHigh?.since) this.demandHigh = { ...e.demandHigh };
8718
8802
  }
8719
8803
  getSnapshot() {
8720
8804
  const snap = super.getSnapshot();
@@ -8736,6 +8820,7 @@ var EmsKernel = class extends FlowEngine {
8736
8820
  } : {},
8737
8821
  ...this.unclosedGenerationPeriods ? { unclosedGenerationPeriods: this.unclosedGenerationPeriods } : {},
8738
8822
  ...this.lastBill ? { lastBill: { ...this.lastBill } } : {},
8823
+ ...this.demandHigh ? { demandHigh: { ...this.demandHigh } } : {},
8739
8824
  ...contractKW !== void 0 ? { contractKW } : {},
8740
8825
  /* 물류 흐름 요청을 받은 적이 있나 — 있으면 이 트윈에 엉뚱한 명령이 오고 있다는 사실이다. */
8741
8826
  ...this.flowRequests.size ? { flowRequests: [...this.flowRequests.entries()].map(([hook, count]) => ({ hook, count })) } : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.74",
3
+ "version": "0.7.75",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {