@operato/twin-kernel 0.7.68 → 0.7.69

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.
@@ -53,6 +53,10 @@ export interface GeneratingState {
53
53
  exportKW?: number;
54
54
  generatedKWh?: number;
55
55
  generatedKWhResetAt?: string;
56
+ /** 지금 든 적산이 무엇부터 쌓인 것인가 — 원본이 말했을 때만 있다. */
57
+ generatedKWhSince?: string;
58
+ /** 되돌아간 판정이 사실인가 짐작인가 — 'declared' 또는 'inferred'. */
59
+ generatedKWhBasis?: 'declared' | 'inferred';
56
60
  }
57
61
  /** 저장 — 담고 낸다. 충전과 방전을 나눈다(손실·수명 판단이 둘을 구별해야 한다). */
58
62
  export interface StoringState {
@@ -86,7 +86,7 @@ export const CAPABILITIES = {
86
86
  * 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
87
87
  * 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
88
88
  */
89
- stateFields: ['generatedKW', 'exportKW', 'generatedKWh', 'generatedKWhResetAt'], models: ['GeneratingState'], results: ['generated']
89
+ stateFields: ['generatedKW', 'exportKW', 'generatedKWh', 'generatedKWhResetAt', 'generatedKWhSince', 'generatedKWhBasis'], models: ['GeneratingState'], results: ['generated']
90
90
  },
91
91
  /*
92
92
  * ── 왜 `storing` 이 아니라 `energyStoring` 인가 (2026-08-15) ────────────────
@@ -1378,6 +1378,23 @@ export interface EquipmentState extends EffectivePeriod {
1378
1378
  * 되돌아간 적이 없으면 이 칸이 없다.
1379
1379
  */
1380
1380
  generatedKWhResetAt?: ISOTime;
1381
+ /**
1382
+ * 지금 든 적산이 **무엇부터 쌓인 것인가** — 원본이 말했을 때만 있다 (2026-08-28).
1383
+ *
1384
+ * 이 값이 있으면 차분을 구하는 쪽이 구간을 안다. 그리고 값이 줄었을 때 그것이 계기의 이상인지
1385
+ * (기준점 그대로) 구간이 새로 시작한 것인지(기준점이 나아갔다) 갈릴 수 있다.
1386
+ */
1387
+ generatedKWhSince?: ISOTime;
1388
+ /**
1389
+ * `generatedKWhResetAt` 이 **사실인가 짐작인가**.
1390
+ *
1391
+ * 'declared' 기준점을 받았고 그것이 그대로인데 값이 줄었다 — 계기의 이상이다
1392
+ * 'inferred' 기준점을 받지 못했고 값이 줄었다 — 계기 교체인지 구간 경계인지 **모른다**
1393
+ *
1394
+ * 예전에는 둘을 구별하지 않고 언제나 「되돌아갔다」로 적었다. 그러면 하루 누적을 주는 원본에서 매일
1395
+ * 자정이 계기 교체로 보인다. 짐작을 사실처럼 적지 않기 위한 칸이다.
1396
+ */
1397
+ generatedKWhBasis?: 'declared' | 'inferred';
1381
1398
  /** storing — 충전율(%)·충전·방전(kW). 충전과 방전을 나눈다(손실·수명 판단이 그 둘을 구별한다). */
1382
1399
  soc?: number;
1383
1400
  chargeKW?: number;
@@ -2405,6 +2422,29 @@ export interface EnergyGeneratedData {
2405
2422
  * 다르며 다를 때 맞는 쪽은 계기다(계량 쪽과 같은 규율).
2406
2423
  */
2407
2424
  kWh: number;
2425
+ /**
2426
+ * **이 적산이 쌓이기 시작한 시각** — 원본이 말했을 때만 있다 (2026-08-28).
2427
+ *
2428
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
2429
+ * 계기가 주는 적산이 어느 구간의 것인지는 **원본마다 다르다** — 설치 이후·하루·한 달·요금기간.
2430
+ * 표준도 그것을 갈라 둔다(계측값에 구간을 붙인다). 그런데 이 문은 한 뜻만 가정하고 있었다:
2431
+ * 단조 증가하는 총적산. 그래서 값이 줄면 **계기 교체로 추론했다.**
2432
+ *
2433
+ * 하루 누적을 주는 원본에서는 그 추론이 매일 자정마다 참이 된다 — 실측(PPMS 인버터):
2434
+ *
2435
+ * 2026-08-25 23:37 KST acc 574.8
2436
+ * 2026-08-28 18:25 KST acc 165.2 ← 줄었다. 자정에 되돌아간다
2437
+ *
2438
+ * 그대로 받으면 「계기 교체가 하루에 한 번 일어난다」가 기록된다.
2439
+ *
2440
+ * ── 왜 시각인가(구간의 이름이 아니라) ───────────────────────────────────────
2441
+ * 한 축으로 하루·한 달·요금기간·설치 이후를 다 담고, 커널이 구간을 이어 붙일 근거도 그 시각이다.
2442
+ * 「요금기간」 같은 이름은 경계를 계약이 정하므로 이름만으로는 커널이 알 수 없다.
2443
+ *
2444
+ * **없으면 「모른다」다** — 설치 이후로 가정하지 않는다. 그때 커널은 지금처럼 추론하고, 그 추론이
2445
+ * 짐작이라는 사실을 상태에 남긴다(§`generatedKWhBasis`).
2446
+ */
2447
+ since?: ISOTime;
2408
2448
  at: ISOTime;
2409
2449
  }
2410
2450
  /** 계량 도착의 실린 값 — 계량 지점 하나의 한 시점. */
@@ -2415,6 +2455,14 @@ export interface EnergyMeasuredData {
2415
2455
  kW: number;
2416
2456
  /** 누적 전력량(kWh) — 계기 누적값. 차분은 소비처가 한다(계기 교체·리셋을 우리가 지어내지 않는다). */
2417
2457
  kWh?: number;
2458
+ /**
2459
+ * 이 적산이 쌓이기 시작한 시각 — 원본이 말했을 때만 있다(§`EnergyGeneratedData.since`).
2460
+ *
2461
+ * 계량 쪽의 셈은 이 값 없이도 정직하다 — 적산이 줄면 그 구간의 전력량을 **내지 않는다**(음수를
2462
+ * 만들지 않는다). 이 축은 셈을 바꾸지 않고 **왜 비는지**를 말할 수 있게 한다: 「계기가 교체돼서」와
2463
+ * 「구간이 바뀌어서」는 다른 사실이고, 화면이 그 둘을 같은 빈칸으로 보이면 사람이 계기를 의심한다.
2464
+ */
2465
+ kWhSince?: ISOTime;
2418
2466
  /** 역률 — 없으면 모르는 것이다(1 로 채우지 않는다). */
2419
2467
  powerFactor?: number;
2420
2468
  at: ISOTime;
@@ -322,13 +322,34 @@ export class EmsKernel extends FlowEngine {
322
322
  if (heardAt !== undefined && atMs < heardAt)
323
323
  return;
324
324
  this.generationHeardAtMs.set(id, atMs);
325
+ /*
326
+ * ── 되돌아간 적산 — **세 갈래로 가른다** (2026-08-28) ───────────────────────
327
+ *
328
+ * 예전에는 갈래가 하나였다: 값이 줄면 계기 교체다. 그런데 **하루 누적을 주는 원본**에서는 그
329
+ * 판정이 매일 자정마다 참이 된다 — 실측(PPMS 인버터): 574.8 → 165.2, 자정에 되돌아간다.
330
+ * 그러면 「계기 교체가 하루에 한 번 일어난다」가 지난 기록에 남는다.
331
+ *
332
+ * 원본이 **기준점**을 말해 주면 그 추론이 필요 없어진다(§`EnergyGeneratedData.since`).
333
+ *
334
+ * 기준점 그대로 · 값이 줄었다 계기의 이상이다 → resetAt · basis 'declared'
335
+ * 기준점이 나아갔다 구간이 새로 시작했다 → resetAt 을 찍지 않는다
336
+ * 기준점이 없다 · 값이 줄었다 모른다 → resetAt · basis 'inferred'
337
+ *
338
+ * 셋째가 예전의 전부였다. 짐작을 사실처럼 적지 않기 위해 그것에 표를 남긴다.
339
+ */
325
340
  const before = Number(eq.generatedKWh);
326
- if (Number.isFinite(before) && kWh < before) {
341
+ const priorSince = eq.generatedKWhSince;
342
+ const since = typeof d?.since === 'string' && d.since.trim() ? d.since.trim() : undefined;
343
+ const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
344
+ if (Number.isFinite(before) && kWh < before && !periodAdvanced) {
327
345
  /* 되돌아갔다 — 값은 새것으로 두고, 그 사실을 시각으로 남긴다(우리가 계단을 메우지 않는다). */
328
346
  ;
329
347
  eq.generatedKWhResetAt = new Date(atMs).toISOString();
348
+ eq.generatedKWhBasis = since ? 'declared' : 'inferred';
330
349
  }
331
- ;
350
+ /* 기준점은 온 것만 세운다 — 오지 않았다고 앞서 받은 것을 지우지 않는다(결측 ≠ 없음). */
351
+ if (since)
352
+ eq.generatedKWhSince = since;
332
353
  eq.generatedKWh = kWh;
333
354
  eq.measuredAt = new Date(atMs).toISOString();
334
355
  this.revision++;
@@ -65,6 +65,17 @@ export interface EnergyGenerationRecord {
65
65
  equipmentId: string;
66
66
  /** 계기 적산값(kWh) — 연결된 시스템이 준 값 그대로. 차분은 소비처가 한다. */
67
67
  kWh?: number;
68
+ /**
69
+ * **이 적산이 쌓이기 시작한 시각**(ISO) — 원본이 그 뜻을 말할 때만 싣는다 (2026-08-28).
70
+ *
71
+ * 하루 누적을 주는 원본이 있다. 그 값을 총적산으로 받으면 **매일 자정이 계기 교체로 기록된다**
72
+ * (§`EnergyGeneratedData.since` 의 실측).
73
+ *
74
+ * **없으면 비운다** — 설치 이후라고 가정하지 않는다. 원본이 말하지 않은 것을 커넥터가 지어내지
75
+ * 않는 것이 이 문의 규율이다. 다만 원본의 **문서나 코드가 그 뜻을 말한다면** 그것을 커널 어휘로
76
+ * 옮기는 것은 지어내기가 아니다 — 그때 현장의 선언된 시간대로 그 구간의 시작을 적는다.
77
+ */
78
+ kWhSince?: string;
68
79
  at?: string;
69
80
  }
70
81
  /** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
@@ -233,11 +233,29 @@ export function ingestEnergyGenerationRecords(records, opts) {
233
233
  const eventTime = String(r?.at ?? '').trim() || opts.defaultEventTime || '';
234
234
  if (!eventTime)
235
235
  errors.push('at 이 없고 기본 시각도 주지 않았다 — 언제 잰 것인지 지어낼 수 없다');
236
+ /*
237
+ * 기준점은 **말했을 때만** 받는다. 형식이 틀리면 거부한다 — 못 읽는 기준점을 통과시키면 커널이
238
+ * 그것을 「기준점이 없다」로 읽고 다시 추론으로 떨어지는데, 커넥터는 알렸다고 믿는다.
239
+ */
240
+ let since;
241
+ const rawSince = r?.kWhSince;
242
+ if (rawSince !== undefined && rawSince !== null && String(rawSince).trim()) {
243
+ const text = String(rawSince).trim();
244
+ const at = Date.parse(text);
245
+ if (!Number.isFinite(at))
246
+ errors.push(`kWhSince 를 시각으로 읽을 수 없다: ${JSON.stringify(rawSince)}`);
247
+ else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
248
+ /* 미래에서 쌓이기 시작한 적산은 없다 — 두 값 중 하나가 틀렸고, 어느 쪽인지 우리가 고를 수 없다. */
249
+ errors.push(`kWhSince(${text}) 가 잰 시각(${eventTime})보다 뒤다 — 둘 중 하나가 틀렸다`);
250
+ }
251
+ else
252
+ since = text;
253
+ }
236
254
  if (errors.length) {
237
255
  rejected.push({ record: r, errors });
238
256
  continue;
239
257
  }
240
- const data = { equipmentId, kWh, at: eventTime };
258
+ const data = { equipmentId, kWh, at: eventTime, ...(since ? { since } : {}) };
241
259
  accepted.push({
242
260
  eventId: `${opts.tenantId}-generated-${++seq}`,
243
261
  eventType: ENERGY_EVENT.generated,
@@ -2710,7 +2710,7 @@ var CAPABILITIES = {
2710
2710
  * 계량 능력(`metered`)이 `kW` 와 `kWh` 를 함께 선언한 것과 같은 짝이다. 적산은 발전 설비의 보편적
2711
2711
  * 사실이고(태양광·열병합·디젤), 그것이 없으면 성능비·발전시간 같은 성과를 아무도 셀 수 없다.
2712
2712
  */
2713
- stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt"],
2713
+ stateFields: ["generatedKW", "exportKW", "generatedKWh", "generatedKWhResetAt", "generatedKWhSince", "generatedKWhBasis"],
2714
2714
  models: ["GeneratingState"],
2715
2715
  results: ["generated"]
2716
2716
  },
@@ -7927,11 +7927,15 @@ var EmsKernel = class extends FlowEngine {
7927
7927
  if (heardAt !== void 0 && atMs < heardAt) return;
7928
7928
  this.generationHeardAtMs.set(id, atMs);
7929
7929
  const before = Number(eq.generatedKWh);
7930
- if (Number.isFinite(before) && kWh < before) {
7930
+ const priorSince = eq.generatedKWhSince;
7931
+ const since = typeof d?.since === "string" && d.since.trim() ? d.since.trim() : void 0;
7932
+ const periodAdvanced = !!since && !!priorSince && Date.parse(since) > Date.parse(priorSince);
7933
+ if (Number.isFinite(before) && kWh < before && !periodAdvanced) {
7931
7934
  ;
7932
7935
  eq.generatedKWhResetAt = new Date(atMs).toISOString();
7936
+ eq.generatedKWhBasis = since ? "declared" : "inferred";
7933
7937
  }
7934
- ;
7938
+ if (since) eq.generatedKWhSince = since;
7935
7939
  eq.generatedKWh = kWh;
7936
7940
  eq.measuredAt = new Date(atMs).toISOString();
7937
7941
  this.revision++;
@@ -9451,11 +9455,21 @@ function ingestEnergyGenerationRecords(records, opts) {
9451
9455
  }
9452
9456
  const eventTime = String(r?.at ?? "").trim() || opts.defaultEventTime || "";
9453
9457
  if (!eventTime) errors.push("at \uC774 \uC5C6\uACE0 \uAE30\uBCF8 \uC2DC\uAC01\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4 \u2014 \uC5B8\uC81C \uC7B0 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
9458
+ let since;
9459
+ const rawSince = r?.kWhSince;
9460
+ if (rawSince !== void 0 && rawSince !== null && String(rawSince).trim()) {
9461
+ const text = String(rawSince).trim();
9462
+ const at = Date.parse(text);
9463
+ if (!Number.isFinite(at)) errors.push(`kWhSince \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(rawSince)}`);
9464
+ else if (Number.isFinite(Date.parse(eventTime)) && at > Date.parse(eventTime)) {
9465
+ errors.push(`kWhSince(${text}) \uAC00 \uC7B0 \uC2DC\uAC01(${eventTime})\uBCF4\uB2E4 \uB4A4\uB2E4 \u2014 \uB458 \uC911 \uD558\uB098\uAC00 \uD2C0\uB838\uB2E4`);
9466
+ } else since = text;
9467
+ }
9454
9468
  if (errors.length) {
9455
9469
  rejected.push({ record: r, errors });
9456
9470
  continue;
9457
9471
  }
9458
- const data = { equipmentId, kWh, at: eventTime };
9472
+ const data = { equipmentId, kWh, at: eventTime, ...since ? { since } : {} };
9459
9473
  accepted.push({
9460
9474
  eventId: `${opts.tenantId}-generated-${++seq}`,
9461
9475
  eventType: ENERGY_EVENT.generated,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.68",
3
+ "version": "0.7.69",
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": {