@operato/twin-kernel 0.7.83 → 0.7.85

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.
@@ -2743,6 +2743,27 @@ export interface EnergyMeasuredData {
2743
2743
  * 않은 것이다. 태양광이면 대개 0 이지만 그것은 현장이 판단할 일이고, 재지 않은 것을 0 으로 적으면
2744
2744
  * 우리가 그 판단을 대신한 것이 된다.
2745
2745
  */
2746
+ /**
2747
+ * 사실의 **주체를 무엇으로 불렀나** — 그 이름이 어디까지 통하는지가 여기서 갈린다.
2748
+ *
2749
+ * ── 왜 이 축이 필요한가 (2026-08-30) ──────────────────────────────────────
2750
+ * 마감된 구간 사실은 정체성이 내용에 있다(종류·주체·시작·끝). 그런데 **주체를 설비 번호로 불렀다.**
2751
+ * 설비 번호는 한 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이
2752
+ * **한 사실**이 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는
2753
+ * 성과 화면에서 한쪽 발전량이 사라졌다.
2754
+ *
2755
+ * 고치는 방법은 두 가지였고 하나는 틀렸다. **트윈 id 를 이름에 붙이는 것은 그릇으로 정체성을 정하는
2756
+ * 것**이라 원칙을 뒤집는다(같은 설비를 현장 트윈과 상위 집계 트윈에 두면 같은 날이 두 사실이 된다).
2757
+ *
2758
+ * 그래서 이름의 출처를 밝힌다.
2759
+ *
2760
+ * declared 모델이 선언한 설비 정체성 — 그릇과 무관하게 고유하다
2761
+ * twin-local 선언이 없어 이름표를 그대로 썼다 — **이 트윈 안에서만 통한다**
2762
+ *
2763
+ * 커널은 없는 이름을 지어내지 않고, 겹치는 이름을 고유한 척하지도 않는다. 읽는 쪽은 `twin-local` 을
2764
+ * 볼 때 트윈 밖에서 같다고 판정하면 안 된다.
2765
+ */
2766
+ export type SubjectBasis = 'declared' | 'twin-local';
2746
2767
  export interface EnergyGenerationPeriodData {
2747
2768
  equipmentId: string;
2748
2769
  /** 이 기간에 만든 양(kWh). */
@@ -2756,6 +2777,9 @@ export interface EnergyGenerationPeriodData {
2756
2777
  accumulation: 'lifetime' | 'daily' | 'monthly' | 'billing';
2757
2778
  /** 기간의 경계가 어디서 왔나 — 원본의 기준점(`declared`)이거나 현장의 시각 기준(`offset`). */
2758
2779
  boundary: 'declared' | 'offset';
2780
+ /** 이 사실이 무엇에 대한 것인가 — 정체성으로 부른 이름(§`SubjectBasis`). */
2781
+ subject?: string;
2782
+ subjectBasis?: SubjectBasis;
2759
2783
  }
2760
2784
  /**
2761
2785
  * 값이 어디서 왔나 — **관측의 근거**.
@@ -2786,6 +2810,9 @@ export interface EnergyUsagePeriodData {
2786
2810
  unitPrice?: number;
2787
2811
  currency?: string;
2788
2812
  basis?: ObservationBasis;
2813
+ /** 이 사실이 무엇에 대한 것인가 — 계량 지점도 설비와 같은 문제를 갖는다(§`SubjectBasis`). */
2814
+ subject?: string;
2815
+ subjectBasis?: SubjectBasis;
2789
2816
  }
2790
2817
  /**
2791
2818
  * 청구서 — `ENERGY_EVENT.bill` 의 데이터.
@@ -3104,6 +3131,7 @@ export interface TwinModelDef {
3104
3131
  id: string;
3105
3132
  kind: string;
3106
3133
  homeLocation: string;
3134
+ identity?: string;
3107
3135
  mtbfMs?: number;
3108
3136
  mttrMs?: number;
3109
3137
  window?: {
@@ -26,6 +26,8 @@
26
26
  * **관측 시작 이후 최대**만 답하고 그 사실을 이름으로 말한다(`peakSince`). 지어내지 않는다.
27
27
  */
28
28
  import { FlowEngine } from "./flow-engine.js";
29
+ /* 기간 사실의 이름은 유입 문과 한 규칙이다(§`periodFactId`). */
30
+ import { periodFactId } from "./energy-ingest.js";
29
31
  import { firstFitPolicy } from "./allocation-policy.js";
30
32
  import { CMD, ENERGY_EVENT, minuteOfDayAt, localDayIndexAt, localDayStartMs } from "./contract.js";
31
33
  import { EMS_PROPERTY, METER_DIRECTION, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
@@ -659,16 +661,25 @@ export class EmsKernel extends FlowEngine {
659
661
  */
660
662
  const total = obs.lastKWh;
661
663
  if (Number.isFinite(total) && total >= 0) {
662
- this.emitOp(ENERGY_EVENT.generationPeriod, {
664
+ /*
665
+ * 이름을 **내용에서** 만든다 — 같은 하루를 커널이 닫은 것과 커넥터가 지난 기록으로 보낸 것이
666
+ * 한 사실이어야 한다(§`emitPeriodFact`).
667
+ */
668
+ const periodStart = new Date(startMs).toISOString();
669
+ const periodEnd = new Date(endMs).toISOString();
670
+ const subj = this.subjectOf('equipment', id);
671
+ this.emitPeriodFact(ENERGY_EVENT.generationPeriod, {
663
672
  equipmentId: id,
664
673
  kWh: total,
665
- periodStart: new Date(startMs).toISOString(),
666
- periodEnd: new Date(endMs).toISOString(),
674
+ periodStart,
675
+ periodEnd,
667
676
  observedFrom: new Date(obs.fromMs).toISOString(),
668
677
  observedTo: new Date(obs.lastMs).toISOString(),
669
678
  accumulation: at.kind,
670
- boundary
671
- });
679
+ boundary,
680
+ subject: subj.subject,
681
+ subjectBasis: subj.basis
682
+ }, periodFactId(this.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, periodStart, periodEnd));
672
683
  eq.generatedKWhLastPeriod = total;
673
684
  eq.generatedKWhLastPeriodEnd = new Date(endMs).toISOString();
674
685
  /* 상태만 읽는 화면이 덜 잰 값을 온전한 값으로 읽지 않게, 관측 구간을 함께 남긴다. */
@@ -20,6 +20,21 @@ export interface EnergyIngestOptions {
20
20
  tenantId: string;
21
21
  /** 레코드에 시각이 없을 때 쓸 값 — **주지 않으면 그 레코드를 거부한다.** */
22
22
  defaultEventTime?: string;
23
+ /**
24
+ * 자원의 **선언된 정체성**을 답한다 — 없으면 `undefined`.
25
+ *
26
+ * 마감된 구간 사실의 이름에 들어갈 주체를 이것으로 정한다(§`SubjectBasis`). 커널이 이름을
27
+ * 지어내지 않는 것과 같은 자세다: 모델이 선언하지 않았으면 답하지 않고, 호출부가 무엇을 할지 정한다.
28
+ */
29
+ identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined;
30
+ /**
31
+ * 선언된 정체성이 없을 때 이름표가 **어디까지 통하는지** — 보통 이 트윈의 id.
32
+ *
33
+ * 이름에 넣는 것이 아니라 **범위를 밝히는 데** 쓴다. 이것이 있으면 사실은 `twin-local` 로 표시되고,
34
+ * 읽는 쪽은 그 이름을 트윈 밖에서 같다고 판정하지 않는다. 주지 않으면 범위를 모르는 것이므로
35
+ * 그때도 `twin-local` 이되 범위는 비어 있다.
36
+ */
37
+ scopeId?: string;
23
38
  }
24
39
  export interface EnergyIngestResult {
25
40
  accepted: CanonicalEnvelope[];
@@ -115,6 +130,34 @@ export interface EnergyUsagePeriodRecord {
115
130
  basis?: string;
116
131
  }
117
132
  export declare function isEnergyUsagePeriodRecord(record: unknown): boolean;
133
+ /**
134
+ * 마감된 **발전 기간** — 「이 기간에 이 설비가 이만큼 냈다」.
135
+ *
136
+ * 라이브에서는 커널이 스스로 마감해 낸다(§`closeGenerationPeriod`). 이 문은 **지난 기록을 채울 때**
137
+ * 쓴다 — 원본이 날짜별 발전량을 주는 현장에서, 트윈이 없던 동안의 날들을 뒤늦게 넣는다.
138
+ *
139
+ * 상태를 바꾸지 않는다. 저널에만 적히고, 저널을 읽는 쪽(성과 계산)이 그 사실을 본다.
140
+ */
141
+ export interface EnergyGenerationPeriodRecord {
142
+ equipmentId: string;
143
+ from: string;
144
+ to: string;
145
+ kWh: number;
146
+ /** 원본이 준 적산의 종류 — 총량을 어떻게 얻었는지가 이것으로 갈린다. */
147
+ accumulation?: string;
148
+ /** 그 기간에서 처음·마지막으로 관측한 시각. 기간 경계와 다르면 그만큼 재지 않았다. */
149
+ observedFrom?: string;
150
+ observedTo?: string;
151
+ }
152
+ export declare function isEnergyGenerationPeriodRecord(record: unknown): boolean;
153
+ /**
154
+ * 마감된 발전 기간을 봉투로 — **사건 시각은 기간의 끝**이다(그때 성립한다).
155
+ *
156
+ * 적산의 문(`ingestEnergyGenerationRecords`)과 겹치지 않는다: 그쪽은 구간이 없는 시점의 값이고
157
+ * 이쪽은 구간이 있는 마감된 사실이다. 겹치면 지난 기록이 「지금 적산」으로 읽혀 트윈의 시계가
158
+ * 과거로 끌린다.
159
+ */
160
+ export declare function ingestEnergyGenerationPeriodRecords(records: EnergyGenerationPeriodRecord | EnergyGenerationPeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
118
161
  /** 발전 단가 — 이 기간에 낸 전기 1kWh 의 값. 날마다 바뀐다. */
119
162
  export interface EnergyGenerationPriceRecord {
120
163
  from: string;
@@ -157,5 +200,18 @@ export interface EnergyBillRecord {
157
200
  billingDemandKW?: number;
158
201
  }
159
202
  export declare function isEnergyBillRecord(record: unknown): boolean;
203
+ /**
204
+ * 이 사실의 **주체를 무엇으로 부를까** — 선언이 있으면 그 이름, 없으면 이름표와 그 범위.
205
+ *
206
+ * 선언이 없을 때 트윈 범위를 이름에 넣는 이유는 **겹침을 숨기지 않기 위해서**다. 넣지 않으면 서로 다른
207
+ * 설비의 같은 날이 한 사실이 되고, 여러 현장을 함께 계산하는 곳에서 한쪽이 조용히 사라진다(실측).
208
+ * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
209
+ * 척하지 않는다.
210
+ */
211
+ export declare function resolveSubject(kind: 'equipment' | 'meter', localId: string, opts: EnergyIngestOptions): {
212
+ subject: string;
213
+ basis: 'declared' | 'twin-local';
214
+ };
215
+ export declare function periodFactId(tenantId: string, kind: string, subject: string, from: string, to: string): string;
160
216
  export declare function ingestEnergyUsagePeriodRecords(records: EnergyUsagePeriodRecord | EnergyUsagePeriodRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
161
217
  export declare function ingestEnergyBillRecords(records: EnergyBillRecord | EnergyBillRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
@@ -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,87 @@ 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
+ const subj = resolveSubject('equipment', equipmentId, opts);
453
+ data.subject = subj.subject;
454
+ data.subjectBasis = subj.basis;
455
+ accepted.push({
456
+ eventId: periodFactId(opts.tenantId, ENERGY_EVENT.generationPeriod, subj.subject, span.from, span.to),
457
+ eventType: ENERGY_EVENT.generationPeriod,
458
+ eventTime: span.to,
459
+ tenantId: opts.tenantId,
460
+ data
461
+ });
462
+ }
463
+ return { accepted, rejected };
464
+ }
381
465
  export function isEnergyGenerationPriceRecord(record) {
382
466
  const r = record;
383
467
  if (!r || typeof r !== 'object')
@@ -515,7 +599,21 @@ export function isEnergyBillRecord(record) {
515
599
  * **이것만으로 중복이 막히지는 않는다.** 저널은 아직 이 id 를 유일성으로 쓰지 않는다(그 판단은
516
600
  * 호스트의 것이고 드라이버마다 다르다). 여기서 하는 일은 **막을 근거를 만드는 것**이다.
517
601
  */
518
- function periodFactId(tenantId, kind, subject, from, to) {
602
+ /**
603
+ * 이 사실의 **주체를 무엇으로 부를까** — 선언이 있으면 그 이름, 없으면 이름표와 그 범위.
604
+ *
605
+ * 선언이 없을 때 트윈 범위를 이름에 넣는 이유는 **겹침을 숨기지 않기 위해서**다. 넣지 않으면 서로 다른
606
+ * 설비의 같은 날이 한 사실이 되고, 여러 현장을 함께 계산하는 곳에서 한쪽이 조용히 사라진다(실측).
607
+ * 넣되 `subjectBasis` 로 **이 이름이 트윈 안에서만 통한다**고 밝힌다 — 그릇으로 정한 이름을 고유한
608
+ * 척하지 않는다.
609
+ */
610
+ export function resolveSubject(kind, localId, opts) {
611
+ const declared = opts.identityOf?.(kind, localId);
612
+ if (declared)
613
+ return { subject: declared, basis: 'declared' };
614
+ return { subject: opts.scopeId ? `${opts.scopeId}/${localId}` : localId, basis: 'twin-local' };
615
+ }
616
+ export function periodFactId(tenantId, kind, subject, from, to) {
519
617
  /* 대상이 없는 사실(요금 기준·청구서·발전 단가)은 그 자리를 비운다 — 없는 것을 지어내지 않는다. */
520
618
  return [tenantId, kind, subject, from, to].join('|');
521
619
  }
@@ -588,8 +686,11 @@ export function ingestEnergyUsagePeriodRecords(records, opts) {
588
686
  rejected.push({ record: r, errors });
589
687
  continue;
590
688
  }
689
+ const subj = resolveSubject('meter', meterId, opts);
591
690
  const data = {
592
691
  meterId,
692
+ subject: subj.subject,
693
+ subjectBasis: subj.basis,
593
694
  from: span.from,
594
695
  to: span.to,
595
696
  kWh,
@@ -599,7 +700,7 @@ export function ingestEnergyUsagePeriodRecords(records, opts) {
599
700
  ...(basis ? { basis } : {})
600
701
  };
601
702
  accepted.push({
602
- eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, meterId, span.from, span.to),
703
+ eventId: periodFactId(opts.tenantId, ENERGY_EVENT.usagePeriod, subj.subject, span.from, span.to),
603
704
  eventType: ENERGY_EVENT.usagePeriod,
604
705
  /* 구간의 **끝**이 이 사실이 성립한 시각이다 — 시작으로 달면 저널이 그 구간을 미리 안 것이 된다. */
605
706
  eventTime: span.to,
@@ -1282,6 +1282,36 @@ export declare abstract class FlowEngine implements TwinKernel {
1282
1282
  */
1283
1283
  private _correlationId?;
1284
1284
  protected emit(event: EpcisEvent): void;
1285
+ /**
1286
+ * **마감된 구간 사실**을 낸다 — 이름을 내용에서 만든다(순번이 아니다).
1287
+ *
1288
+ * ── 왜 따로 있나 (2026-08-30) ────────────────────────────────────────────
1289
+ * `emitOp` 은 이름을 순번으로 붙인다(`…-evt-3`). 관측이나 전이는 그래도 된다 — 두 번 일어나면 두
1290
+ * 사실이다. 그런데 **마감된 구간은 같은 기간이면 한 사실이다.** 같은 하루를 커널이 자정에 닫고
1291
+ * 커넥터가 지난 기록으로도 보내면, 순번 이름으로는 그 둘이 다른 사실이 되어 **발전량이 두 배로**
1292
+ * 잡힌다. 유입 문은 이미 내용으로 이름을 짓고 있었는데(§`periodFactId`) 커널 자신만 아니었다 —
1293
+ * 한 종류에 규칙이 두 벌이었다.
1294
+ */
1295
+ protected emitPeriodFact(eventType: string, data: unknown, eventId: string): void;
1296
+ /**
1297
+ * 이 트윈의 **범위 이름** — 선언된 정체성이 없을 때 이름표가 어디까지 통하는지(§`SubjectBasis`).
1298
+ *
1299
+ * 호스트가 세운다. 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다** —
1300
+ * 도메인에 현장이 둘이면 설비 번호가 겹치는데 커널만으로는 그것을 알 길이 없었다.
1301
+ */
1302
+ scopeId?: string;
1303
+ /**
1304
+ * 설비의 **선언된 정체성** — 모델이 적었으면 그 이름, 없으면 답하지 않는다.
1305
+ *
1306
+ * 물품과 같은 자세다(§`declaredObjectId`): 선언이 없으면 지어내지 않는다. 명시한 이름이 먼저이고,
1307
+ * 없으면 선언된 이름공간 아래의 개체 식별자로 만든다(CBV §8.2.3·§8.2.4).
1308
+ */
1309
+ equipmentIdentity(equipmentId: string): string | undefined;
1310
+ /** 유입 문과 **같은 규칙**으로 주체를 정한다 — 이름 짓는 자리가 둘이면 한쪽만 고쳐지는 날이 온다. */
1311
+ protected subjectOf(kind: 'equipment' | 'meter', localId: string): {
1312
+ subject: string;
1313
+ basis: 'declared' | 'twin-local';
1314
+ };
1285
1315
  protected emitOp(eventType: string, data: unknown): void;
1286
1316
  /**
1287
1317
  * 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
+ /* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
15
+ import { resolveSubject } from "./energy-ingest.js";
14
16
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from "./epcis.js";
15
17
  import { OP_PARAM } from "./domain-definition.js";
16
18
  import { parseIsoDuration } from "./iso-duration.js";
@@ -2787,6 +2789,46 @@ export class FlowEngine {
2787
2789
  for (const h of this.handlers)
2788
2790
  h(e);
2789
2791
  }
2792
+ /**
2793
+ * **마감된 구간 사실**을 낸다 — 이름을 내용에서 만든다(순번이 아니다).
2794
+ *
2795
+ * ── 왜 따로 있나 (2026-08-30) ────────────────────────────────────────────
2796
+ * `emitOp` 은 이름을 순번으로 붙인다(`…-evt-3`). 관측이나 전이는 그래도 된다 — 두 번 일어나면 두
2797
+ * 사실이다. 그런데 **마감된 구간은 같은 기간이면 한 사실이다.** 같은 하루를 커널이 자정에 닫고
2798
+ * 커넥터가 지난 기록으로도 보내면, 순번 이름으로는 그 둘이 다른 사실이 되어 **발전량이 두 배로**
2799
+ * 잡힌다. 유입 문은 이미 내용으로 이름을 짓고 있었는데(§`periodFactId`) 커널 자신만 아니었다 —
2800
+ * 한 종류에 규칙이 두 벌이었다.
2801
+ */
2802
+ emitPeriodFact(eventType, data, eventId) {
2803
+ this.revision++;
2804
+ const e = { eventId, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
2805
+ for (const h of this.handlers)
2806
+ h(e);
2807
+ }
2808
+ /**
2809
+ * 이 트윈의 **범위 이름** — 선언된 정체성이 없을 때 이름표가 어디까지 통하는지(§`SubjectBasis`).
2810
+ *
2811
+ * 호스트가 세운다. 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다** —
2812
+ * 도메인에 현장이 둘이면 설비 번호가 겹치는데 커널만으로는 그것을 알 길이 없었다.
2813
+ */
2814
+ scopeId;
2815
+ /**
2816
+ * 설비의 **선언된 정체성** — 모델이 적었으면 그 이름, 없으면 답하지 않는다.
2817
+ *
2818
+ * 물품과 같은 자세다(§`declaredObjectId`): 선언이 없으면 지어내지 않는다. 명시한 이름이 먼저이고,
2819
+ * 없으면 선언된 이름공간 아래의 개체 식별자로 만든다(CBV §8.2.3·§8.2.4).
2820
+ */
2821
+ equipmentIdentity(equipmentId) {
2822
+ const decl = (this.boardDef?.equipment ?? []).find(e => e.id === equipmentId);
2823
+ const explicit = String(decl?.identity ?? '').trim();
2824
+ if (explicit)
2825
+ return explicit;
2826
+ return this.declaredObjectId(equipmentId);
2827
+ }
2828
+ /** 유입 문과 **같은 규칙**으로 주체를 정한다 — 이름 짓는 자리가 둘이면 한쪽만 고쳐지는 날이 온다. */
2829
+ subjectOf(kind, localId) {
2830
+ return resolveSubject(kind, localId, { tenantId: this.tenantId, scopeId: this.scopeId, identityOf: (k, id) => (k === 'equipment' ? this.equipmentIdentity(id) : undefined) });
2831
+ }
2790
2832
  emitOp(eventType, data) {
2791
2833
  this.revision++;
2792
2834
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
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, resolveSubject, periodFactId } 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, resolveSubject, periodFactId } 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";