@operato/twin-kernel 0.7.82 → 0.7.84

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.
@@ -554,6 +554,20 @@ export class EmsKernel extends FlowEngine {
554
554
  * 「어느 기간의 값인지 모른다」로 마감하지 못한다.
555
555
  */
556
556
  const kind = obs.accumulation ?? eq.generatedKWhAccumulation;
557
+ /*
558
+ * ── 마감하지 못하면 **손대지 않는다** (2026-08-30, 고침) ─────────────────
559
+ *
560
+ * 종류를 모르면 마감할 수 없다. 그런데 그대로 아래로 내려보내면 관측이 **오늘 것으로 덮인다** —
561
+ * 표본이 왔을 때는 그 재설정이 맞지만(새 관측이니까), 시계로 부를 때는 새 표본이 없다.
562
+ * 덮으면 어제는 영영 마감되지 않는다.
563
+ *
564
+ * 실제로 그렇게 됐다: 밤에 재기동해 종류가 비었고, 시계가 자정을 지날 때마다 어제 관측이
565
+ * 오늘로 밀렸다. 아침 표본이 와도 마감할 어제가 남아 있지 않다.
566
+ */
567
+ if (kind === undefined || kind === 'unknown') {
568
+ this.unclosedGenerationPeriods++;
569
+ continue;
570
+ }
557
571
  /*
558
572
  * 마감은 `applyGenerated` 와 **같은 함수**로 한다 — 규칙을 두 벌 만들면 표본으로 닫은 날과
559
573
  * 시계로 닫은 날이 다른 값을 낸다.
@@ -115,6 +115,34 @@ export interface EnergyUsagePeriodRecord {
115
115
  basis?: string;
116
116
  }
117
117
  export declare function isEnergyUsagePeriodRecord(record: unknown): boolean;
118
+ /**
119
+ * 마감된 **발전 기간** — 「이 기간에 이 설비가 이만큼 냈다」.
120
+ *
121
+ * 라이브에서는 커널이 스스로 마감해 낸다(§`closeGenerationPeriod`). 이 문은 **지난 기록을 채울 때**
122
+ * 쓴다 — 원본이 날짜별 발전량을 주는 현장에서, 트윈이 없던 동안의 날들을 뒤늦게 넣는다.
123
+ *
124
+ * 상태를 바꾸지 않는다. 저널에만 적히고, 저널을 읽는 쪽(성과 계산)이 그 사실을 본다.
125
+ */
126
+ export interface EnergyGenerationPeriodRecord {
127
+ equipmentId: string;
128
+ from: string;
129
+ to: string;
130
+ kWh: number;
131
+ /** 원본이 준 적산의 종류 — 총량을 어떻게 얻었는지가 이것으로 갈린다. */
132
+ accumulation?: string;
133
+ /** 그 기간에서 처음·마지막으로 관측한 시각. 기간 경계와 다르면 그만큼 재지 않았다. */
134
+ observedFrom?: string;
135
+ observedTo?: string;
136
+ }
137
+ export declare function isEnergyGenerationPeriodRecord(record: unknown): boolean;
138
+ /**
139
+ * 마감된 발전 기간을 봉투로 — **사건 시각은 기간의 끝**이다(그때 성립한다).
140
+ *
141
+ * 적산의 문(`ingestEnergyGenerationRecords`)과 겹치지 않는다: 그쪽은 구간이 없는 시점의 값이고
142
+ * 이쪽은 구간이 있는 마감된 사실이다. 겹치면 지난 기록이 「지금 적산」으로 읽혀 트윈의 시계가
143
+ * 과거로 끌린다.
144
+ */
145
+ export declare function ingestEnergyGenerationPeriodRecords(records: EnergyGenerationPeriodRecord | EnergyGenerationPeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
118
146
  /** 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. 날마다 바뀐다. */
119
147
  export interface EnergyGenerationPriceRecord {
120
148
  from: string;
@@ -268,6 +268,9 @@ export function ingestEnergyEquipmentRecords(records, opts) {
268
268
  }
269
269
  /** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
270
270
  export function isEnergyGenerationRecord(record) {
271
+ /* 구간이 있으면 마감된 발전 기간이지 시점의 적산이 아니다 — 겹치면 지난 기록이 「지금」으로 읽힌다. */
272
+ if (record?.from !== undefined || record?.to !== undefined)
273
+ return false;
271
274
  if (!record || typeof record !== 'object')
272
275
  return false;
273
276
  const r = record;
@@ -378,6 +381,84 @@ export function isEnergyUsagePeriodRecord(record) {
378
381
  const r = record;
379
382
  return !!r && typeof r === 'object' && r.meterId !== undefined && r.from !== undefined && r.to !== undefined && r.kWh !== undefined;
380
383
  }
384
+ export function isEnergyGenerationPeriodRecord(record) {
385
+ const r = record;
386
+ if (!r || typeof r !== 'object')
387
+ return false;
388
+ if (r.equipmentId === undefined || r.from === undefined || r.to === undefined)
389
+ return false;
390
+ return r.kWh !== undefined;
391
+ }
392
+ /**
393
+ * 마감된 발전 기간을 봉투로 — **사건 시각은 기간의 끝**이다(그때 성립한다).
394
+ *
395
+ * 적산의 문(`ingestEnergyGenerationRecords`)과 겹치지 않는다: 그쪽은 구간이 없는 시점의 값이고
396
+ * 이쪽은 구간이 있는 마감된 사실이다. 겹치면 지난 기록이 「지금 적산」으로 읽혀 트윈의 시계가
397
+ * 과거로 끌린다.
398
+ */
399
+ export function ingestEnergyGenerationPeriodRecords(records, opts) {
400
+ const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
401
+ const accepted = [];
402
+ const rejected = [];
403
+ for (const r of list) {
404
+ const errors = [];
405
+ const equipmentId = String(r?.equipmentId ?? '').trim();
406
+ if (!equipmentId)
407
+ errors.push('equipmentId 가 없다 — 어느 설비가 낸 것인지 지어낼 수 없다');
408
+ const span = readSpan(r, errors);
409
+ const kWh = readAmount(r?.kWh, 'kWh', errors);
410
+ if (kWh === undefined && !errors.length)
411
+ errors.push('kWh 가 없다 — 이 문이 받는 값은 그 기간의 발전량이다');
412
+ const ACCUMULATIONS = ['lifetime', 'daily', 'monthly', 'billing', 'unknown'];
413
+ let accumulation;
414
+ const rawAcc = r?.accumulation;
415
+ if (rawAcc !== undefined && rawAcc !== null && String(rawAcc).trim()) {
416
+ const text = String(rawAcc).trim();
417
+ if (!ACCUMULATIONS.includes(text)) {
418
+ errors.push(`accumulation 이 아는 값이 아니다(${ACCUMULATIONS.join('·')}): ${JSON.stringify(rawAcc)}`);
419
+ }
420
+ else
421
+ accumulation = text;
422
+ }
423
+ /* 관측 구간은 기간 안에 있어야 한다 — 밖이면 그 값이 이 기간의 것이 아니다. */
424
+ const readAt = (raw, name) => {
425
+ if (raw === undefined || raw === null || !String(raw).trim())
426
+ return undefined;
427
+ const text = String(raw).trim();
428
+ if (!Number.isFinite(Date.parse(text))) {
429
+ errors.push(`${name} 를 시각으로 읽을 수 없다: ${JSON.stringify(raw)}`);
430
+ return undefined;
431
+ }
432
+ return text;
433
+ };
434
+ const observedFrom = readAt(r?.observedFrom, 'observedFrom');
435
+ const observedTo = readAt(r?.observedTo, 'observedTo');
436
+ if (errors.length || !span || kWh === undefined) {
437
+ rejected.push({ record: r, errors });
438
+ continue;
439
+ }
440
+ const data = {
441
+ equipmentId,
442
+ kWh,
443
+ periodStart: span.from,
444
+ periodEnd: span.to,
445
+ /* 관측 구간을 말하지 않으면 기간 전체를 잰 것으로 둔다 — 지난 기록은 그 원본이 하루를 마감해 준 값이다. */
446
+ observedFrom: observedFrom ?? span.from,
447
+ observedTo: observedTo ?? span.to,
448
+ accumulation: (accumulation ?? 'daily'),
449
+ /* 밖에서 마감되어 온 것이다 — 우리가 시각 기준으로 닫은 것이 아니다. */
450
+ boundary: 'declared'
451
+ };
452
+ accepted.push({
453
+ eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, equipmentId, span.from, span.to),
454
+ eventType: ENERGY_EVENT.generationPeriod,
455
+ eventTime: span.to,
456
+ tenantId: opts.tenantId,
457
+ data
458
+ });
459
+ }
460
+ return { accepted, rejected };
461
+ }
381
462
  export function isEnergyGenerationPriceRecord(record) {
382
463
  const r = record;
383
464
  if (!r || typeof r !== 'object')
package/dist/index.d.ts CHANGED
@@ -30,10 +30,10 @@ export { WmsKernel } from './kernel.ts';
30
30
  export { YmsKernel } from './yms-kernel.ts';
31
31
  export { MesKernel } from './mes-kernel.ts';
32
32
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from './ems-kernel.ts';
33
- export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord, ingestEnergyGenerationPriceRecords, isEnergyGenerationPriceRecord } from './energy-ingest.ts';
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord, ingestEnergyGenerationPriceRecords, isEnergyGenerationPriceRecord, ingestEnergyGenerationPeriodRecords, isEnergyGenerationPeriodRecord } from './energy-ingest.ts';
34
34
  export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from './operational-ingest.ts';
35
35
  export type { OperationalKind, OperationalRecord, OperationalIngestOptions } from './operational-ingest.ts';
36
36
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from './energy-attribution.ts';
37
37
  export type { AttributionBasis, AttributionResult, ElectricityCost, EnergyConsumer, EnergyPool, EnergyShare, IntensityInput, IntensityResult, IntensityDenominator, TariffDeclaration, WeightKind, WindowedEnergy } from './energy-attribution.ts';
38
- export type { EnergyRecord, EnergyEquipmentRecord, EnergyGenerationRecord, EnergyUsagePeriodRecord, EnergyBillRecord, EnergyTariffBasisRecord, EnergyGenerationPriceRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
38
+ export type { EnergyRecord, EnergyEquipmentRecord, EnergyGenerationRecord, EnergyUsagePeriodRecord, EnergyBillRecord, EnergyTariffBasisRecord, EnergyGenerationPriceRecord, EnergyGenerationPeriodRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
39
39
  export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { WmsKernel } from "./kernel.js";
30
30
  export { YmsKernel } from "./yms-kernel.js";
31
31
  export { MesKernel } from "./mes-kernel.js";
32
32
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from "./ems-kernel.js";
33
- export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord, ingestEnergyGenerationPriceRecords, isEnergyGenerationPriceRecord } from "./energy-ingest.js";
33
+ export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord, ingestEnergyGenerationRecords, isEnergyGenerationRecord, ingestEnergyUsagePeriodRecords, isEnergyUsagePeriodRecord, ingestEnergyBillRecords, isEnergyBillRecord, ingestEnergyTariffBasisRecords, isEnergyTariffBasisRecord, ingestEnergyGenerationPriceRecords, isEnergyGenerationPriceRecord, ingestEnergyGenerationPeriodRecords, isEnergyGenerationPeriodRecord } from "./energy-ingest.js";
34
34
  /* 운영 사실의 문 — 리듀서가 다루는 여섯이 들어오는 자리(미러가 시뮬보다 가난하지 않게). */
35
35
  export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from "./operational-ingest.js";
36
36
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from "./energy-attribution.js";
@@ -127,6 +127,7 @@ __export(index_exports, {
127
127
  ingest: () => ingest,
128
128
  ingestEnergyBillRecords: () => ingestEnergyBillRecords,
129
129
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
130
+ ingestEnergyGenerationPeriodRecords: () => ingestEnergyGenerationPeriodRecords,
130
131
  ingestEnergyGenerationPriceRecords: () => ingestEnergyGenerationPriceRecords,
131
132
  ingestEnergyGenerationRecords: () => ingestEnergyGenerationRecords,
132
133
  ingestEnergyRecords: () => ingestEnergyRecords,
@@ -138,6 +139,7 @@ __export(index_exports, {
138
139
  isElectricalLocationType: () => isElectricalLocationType,
139
140
  isEnergyBillRecord: () => isEnergyBillRecord,
140
141
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
142
+ isEnergyGenerationPeriodRecord: () => isEnergyGenerationPeriodRecord,
141
143
  isEnergyGenerationPriceRecord: () => isEnergyGenerationPriceRecord,
142
144
  isEnergyGenerationRecord: () => isEnergyGenerationRecord,
143
145
  isEnergyRecord: () => isEnergyRecord,
@@ -8183,6 +8185,10 @@ var EmsKernel = class extends FlowEngine {
8183
8185
  const eq = this.equipment.get(id);
8184
8186
  if (!eq) continue;
8185
8187
  const kind = obs.accumulation ?? eq.generatedKWhAccumulation;
8188
+ if (kind === void 0 || kind === "unknown") {
8189
+ this.unclosedGenerationPeriods++;
8190
+ continue;
8191
+ }
8186
8192
  this.closeGenerationPeriod(id, eq, {
8187
8193
  atMs: nowMs,
8188
8194
  kWh: obs.lastKWh,
@@ -9966,6 +9972,7 @@ function ingestEnergyEquipmentRecords(records, opts) {
9966
9972
  return { accepted, rejected };
9967
9973
  }
9968
9974
  function isEnergyGenerationRecord(record) {
9975
+ if (record?.from !== void 0 || record?.to !== void 0) return false;
9969
9976
  if (!record || typeof record !== "object") return false;
9970
9977
  const r = record;
9971
9978
  return typeof r.equipmentId === "string" && r.equipmentId.trim().length > 0 && r.kWh !== void 0 && r.meterId === void 0 && r.epc === void 0;
@@ -10039,6 +10046,69 @@ function isEnergyUsagePeriodRecord(record) {
10039
10046
  const r = record;
10040
10047
  return !!r && typeof r === "object" && r.meterId !== void 0 && r.from !== void 0 && r.to !== void 0 && r.kWh !== void 0;
10041
10048
  }
10049
+ function isEnergyGenerationPeriodRecord(record) {
10050
+ const r = record;
10051
+ if (!r || typeof r !== "object") return false;
10052
+ if (r.equipmentId === void 0 || r.from === void 0 || r.to === void 0) return false;
10053
+ return r.kWh !== void 0;
10054
+ }
10055
+ function ingestEnergyGenerationPeriodRecords(records, opts) {
10056
+ const list = records === void 0 || records === null ? [] : Array.isArray(records) ? records : [records];
10057
+ const accepted = [];
10058
+ const rejected = [];
10059
+ for (const r of list) {
10060
+ const errors = [];
10061
+ const equipmentId = String(r?.equipmentId ?? "").trim();
10062
+ if (!equipmentId) errors.push("equipmentId \uAC00 \uC5C6\uB2E4 \u2014 \uC5B4\uB290 \uC124\uBE44\uAC00 \uB0B8 \uAC83\uC778\uC9C0 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4");
10063
+ const span = readSpan(r, errors);
10064
+ const kWh = readAmount(r?.kWh, "kWh", errors);
10065
+ if (kWh === void 0 && !errors.length) errors.push("kWh \uAC00 \uC5C6\uB2E4 \u2014 \uC774 \uBB38\uC774 \uBC1B\uB294 \uAC12\uC740 \uADF8 \uAE30\uAC04\uC758 \uBC1C\uC804\uB7C9\uC774\uB2E4");
10066
+ const ACCUMULATIONS = ["lifetime", "daily", "monthly", "billing", "unknown"];
10067
+ let accumulation;
10068
+ const rawAcc = r?.accumulation;
10069
+ if (rawAcc !== void 0 && rawAcc !== null && String(rawAcc).trim()) {
10070
+ const text = String(rawAcc).trim();
10071
+ if (!ACCUMULATIONS.includes(text)) {
10072
+ errors.push(`accumulation \uC774 \uC544\uB294 \uAC12\uC774 \uC544\uB2C8\uB2E4(${ACCUMULATIONS.join("\xB7")}): ${JSON.stringify(rawAcc)}`);
10073
+ } else accumulation = text;
10074
+ }
10075
+ const readAt = (raw, name) => {
10076
+ if (raw === void 0 || raw === null || !String(raw).trim()) return void 0;
10077
+ const text = String(raw).trim();
10078
+ if (!Number.isFinite(Date.parse(text))) {
10079
+ errors.push(`${name} \uB97C \uC2DC\uAC01\uC73C\uB85C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${JSON.stringify(raw)}`);
10080
+ return void 0;
10081
+ }
10082
+ return text;
10083
+ };
10084
+ const observedFrom = readAt(r?.observedFrom, "observedFrom");
10085
+ const observedTo = readAt(r?.observedTo, "observedTo");
10086
+ if (errors.length || !span || kWh === void 0) {
10087
+ rejected.push({ record: r, errors });
10088
+ continue;
10089
+ }
10090
+ const data = {
10091
+ equipmentId,
10092
+ kWh,
10093
+ periodStart: span.from,
10094
+ periodEnd: span.to,
10095
+ /* 관측 구간을 말하지 않으면 기간 전체를 잰 것으로 둔다 — 지난 기록은 그 원본이 하루를 마감해 준 값이다. */
10096
+ observedFrom: observedFrom ?? span.from,
10097
+ observedTo: observedTo ?? span.to,
10098
+ accumulation: accumulation ?? "daily",
10099
+ /* 밖에서 마감되어 온 것이다 — 우리가 시각 기준으로 닫은 것이 아니다. */
10100
+ boundary: "declared"
10101
+ };
10102
+ accepted.push({
10103
+ eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, equipmentId, span.from, span.to),
10104
+ eventType: ENERGY_EVENT.generationPeriod,
10105
+ eventTime: span.to,
10106
+ tenantId: opts.tenantId,
10107
+ data
10108
+ });
10109
+ }
10110
+ return { accepted, rejected };
10111
+ }
10042
10112
  function isEnergyGenerationPriceRecord(record) {
10043
10113
  const r = record;
10044
10114
  if (!r || typeof r !== "object") return false;
@@ -10934,6 +11004,7 @@ function retiredVocabularyIn(line) {
10934
11004
  ingest,
10935
11005
  ingestEnergyBillRecords,
10936
11006
  ingestEnergyEquipmentRecords,
11007
+ ingestEnergyGenerationPeriodRecords,
10937
11008
  ingestEnergyGenerationPriceRecords,
10938
11009
  ingestEnergyGenerationRecords,
10939
11010
  ingestEnergyRecords,
@@ -10945,6 +11016,7 @@ function retiredVocabularyIn(line) {
10945
11016
  isElectricalLocationType,
10946
11017
  isEnergyBillRecord,
10947
11018
  isEnergyEquipmentRecord,
11019
+ isEnergyGenerationPeriodRecord,
10948
11020
  isEnergyGenerationPriceRecord,
10949
11021
  isEnergyGenerationRecord,
10950
11022
  isEnergyRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.82",
3
+ "version": "0.7.84",
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": {