@operato/twin-kernel 0.7.21 → 0.7.22

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.
@@ -140,6 +140,16 @@ export declare class EmsKernel extends FlowEngine {
140
140
  * ④ 미러 트윈에서는 아무것도 만들지 않는다 — 관측 구동은 잰 것만 쓴다.
141
141
  */
142
142
  private deriveLoad;
143
+ /**
144
+ * 선언된 발전을 만든다 — **형상은 현장이 적는다.**
145
+ *
146
+ * 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
147
+ * 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
148
+ *
149
+ * 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
150
+ * 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
151
+ */
152
+ private deriveGeneration;
143
153
  /** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
144
154
  private hasDispatchPolicy;
145
155
  /**
@@ -163,6 +173,8 @@ export declare class EmsKernel extends FlowEngine {
163
173
  private emitEnergyEquipment;
164
174
  /** 자원 속성에서 수 하나 — 값이 수가 아니면 없는 것으로 본다(짐작하지 않는다). */
165
175
  private numberProperty;
176
+ /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
177
+ private stringProperty;
166
178
  tick(dtMs: number): void;
167
179
  getSnapshot(): StateSnapshot;
168
180
  }
@@ -27,8 +27,8 @@
27
27
  */
28
28
  import { FlowEngine } from "./flow-engine.js";
29
29
  import { firstFitPolicy } from "./allocation-policy.js";
30
- import { ENERGY_EVENT } from "./contract.js";
31
- import { EMS_PROPERTY, electricalUpstreamOf } from "./ems-profile.js";
30
+ import { ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
31
+ import { EMS_PROPERTY, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
32
32
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
33
33
  export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
34
34
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
@@ -555,6 +555,14 @@ export class EmsKernel extends FlowEngine {
555
555
  * 음수는 **역송**이지 음의 수요가 아니다. 계통 수요는 0 에서 멈추고, 남는 발전은 그 설비의
556
556
  * `exportKW` 가 이미 사실로 말한다(여기서 다시 만들지 않는다).
557
557
  */
558
+ /*
559
+ * ── 선언된 발전을 먼저 만든다 (2026-08-18) ─────────────────────────────────
560
+ * 정격과 하루 형상을 선언한 설비는 이 시각의 비율만큼 발전한다. 형상을 우리가 지어내지 않으므로
561
+ * (위도·날씨로 곡선을 만들면 그 수의 출처를 설명할 수 없다) 선언이 없으면 발전하지 않는다.
562
+ *
563
+ * 미러에서는 여기까지 오지 않는다(위에서 관측이 진실이라 돌아간다) — 계측이 발전량을 말해 준다.
564
+ */
565
+ this.deriveGeneration(atMs);
558
566
  let generated = 0;
559
567
  let discharged = 0;
560
568
  let charged = 0;
@@ -604,6 +612,43 @@ export class EmsKernel extends FlowEngine {
604
612
  this.revision++;
605
613
  this.judgeOpenWindow(atMs);
606
614
  }
615
+ /**
616
+ * 선언된 발전을 만든다 — **형상은 현장이 적는다.**
617
+ *
618
+ * 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
619
+ * 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
620
+ *
621
+ * 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
622
+ * 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
623
+ */
624
+ deriveGeneration(atMs) {
625
+ for (const e of this.boardDef?.equipment ?? []) {
626
+ const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
627
+ if (rated === undefined || !(rated > 0))
628
+ continue;
629
+ const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
630
+ const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
631
+ const fraction = generationFractionAt(profile, hour);
632
+ const state = this.equipment.get(e.id);
633
+ if (!state)
634
+ continue;
635
+ if (fraction === undefined) {
636
+ /* 형상이 없거나 형태가 아니면 발전하지 않는다 — 낡은 값을 남기지 않는다(지난 시각을 말하게 된다). */
637
+ if (Number(state.generatedKW) > 0) {
638
+ ;
639
+ state.generatedKW = 0;
640
+ this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
641
+ }
642
+ continue;
643
+ }
644
+ const kW = rated * fraction;
645
+ const before = Number(state.generatedKW);
646
+ state.generatedKW = kW;
647
+ /* 값이 실제로 바뀔 때만 사실을 낸다 — 매 틱 같은 값을 되풀면 저널이 소음으로 찬다. */
648
+ if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9))
649
+ this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
650
+ }
651
+ }
607
652
  /** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
608
653
  hasDispatchPolicy(equipmentId) {
609
654
  for (const e of this.boardDef?.equipment ?? []) {
@@ -709,6 +754,17 @@ export class EmsKernel extends FlowEngine {
709
754
  }
710
755
  return undefined;
711
756
  }
757
+ /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
758
+ stringProperty(resource, id) {
759
+ for (const p of resource?.properties ?? []) {
760
+ if (p?.id !== id)
761
+ continue;
762
+ const v = String(p.value ?? '').trim();
763
+ if (v)
764
+ return v;
765
+ }
766
+ return undefined;
767
+ }
712
768
  tick(dtMs) {
713
769
  super.tick(dtMs);
714
770
  /*
@@ -40,6 +40,14 @@ export declare const EMS_PROPERTY: {
40
40
  * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
41
41
  */
42
42
  readonly initialSoc: "storage.initialSoc";
43
+ /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
44
+ readonly genRatedKW: "generation.ratedKW";
45
+ /**
46
+ * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
47
+ *
48
+ * 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
49
+ */
50
+ readonly genDailyProfile: "generation.dailyProfile";
43
51
  };
44
52
  /**
45
53
  * 이 선언들의 **단위와 범위** — 값을 읽는 쪽이 아니라 **주는 쪽**을 위한 표다.
@@ -66,6 +74,13 @@ export interface EmsPropertySpec {
66
74
  note: string;
67
75
  }
68
76
  export declare const EMS_PROPERTY_SPEC: Record<string, EmsPropertySpec>;
77
+ /**
78
+ * 하루 형상에서 **이 시각의 비율**을 읽는다 — 없거나 형태가 아니면 `undefined`(발전하지 않는다).
79
+ *
80
+ * 24개가 아니면 받지 않는다: 값이 몇 개인지 짐작해 늘리거나 자르면, 사람이 적은 곡선과 우리가 쓰는 곡선이
81
+ * 달라진다. 0~1 밖의 값도 받지 않는다(정격의 배수로 발전하는 태양광은 없다).
82
+ */
83
+ export declare function generationFractionAt(profile: string | undefined | null, hourOfDay: number): number | undefined;
69
84
  export declare const EMS_TYPES: TwinTypeInfo[];
70
85
  /**
71
86
  * 이 자리의 **전기 상류** — 어디서 전기를 받는가.
@@ -71,7 +71,28 @@ export const EMS_PROPERTY = {
71
71
  *
72
72
  * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
73
73
  */
74
- initialSoc: 'storage.initialSoc'
74
+ initialSoc: 'storage.initialSoc',
75
+ /*
76
+ * ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
77
+ *
78
+ * 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
79
+ * 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
80
+ * 없었다(what-if 의 값이 절반만 성립했다).
81
+ *
82
+ * **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
83
+ * 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
84
+ * (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
85
+ *
86
+ * 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
87
+ */
88
+ /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
89
+ genRatedKW: 'generation.ratedKW',
90
+ /**
91
+ * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
92
+ *
93
+ * 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
94
+ */
95
+ genDailyProfile: 'generation.dailyProfile'
75
96
  };
76
97
  export const EMS_PROPERTY_SPEC = {
77
98
  [EMS_PROPERTY.contractKW]: { uom: 'kW', dataType: 'xs:double', note: 'contracted power at the metering point, in kW.' },
@@ -85,8 +106,34 @@ export const EMS_PROPERTY_SPEC = {
85
106
  [EMS_PROPERTY.maxDischargeKW]: { uom: 'kW', dataType: 'xs:double', note: 'inverter limit on discharge rate, in kW.' },
86
107
  /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
87
108
  [EMS_PROPERTY.reserveSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below.' },
88
- [EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' }
109
+ [EMS_PROPERTY.initialSoc]: { uom: '%', dataType: 'xs:double', range: [0, 100], note: 'starting state of charge as a percentage 0-100 (80 means 80%, not 0.8).' },
110
+ [EMS_PROPERTY.genRatedKW]: { uom: 'kW', dataType: 'xs:double', note: 'rated generation output at clear-sky noon, in kW.' },
111
+ [EMS_PROPERTY.genDailyProfile]: {
112
+ dataType: 'xs:string',
113
+ note: 'daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time — the site declares its own curve; we do not invent one.'
114
+ }
89
115
  };
116
+ /**
117
+ * 하루 형상에서 **이 시각의 비율**을 읽는다 — 없거나 형태가 아니면 `undefined`(발전하지 않는다).
118
+ *
119
+ * 24개가 아니면 받지 않는다: 값이 몇 개인지 짐작해 늘리거나 자르면, 사람이 적은 곡선과 우리가 쓰는 곡선이
120
+ * 달라진다. 0~1 밖의 값도 받지 않는다(정격의 배수로 발전하는 태양광은 없다).
121
+ */
122
+ export function generationFractionAt(profile, hourOfDay) {
123
+ const parts = String(profile ?? '')
124
+ .split(',')
125
+ .map(v => v.trim())
126
+ .filter(v => v !== '');
127
+ if (parts.length !== 24)
128
+ return undefined;
129
+ const h = Math.floor(hourOfDay);
130
+ if (!Number.isFinite(h) || h < 0 || h > 23)
131
+ return undefined;
132
+ const v = Number(parts[h]);
133
+ if (!Number.isFinite(v) || v < 0 || v > 1)
134
+ return undefined;
135
+ return v;
136
+ }
90
137
  export const EMS_TYPES = [
91
138
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
92
139
  {
@@ -106,6 +106,7 @@ __export(index_exports, {
106
106
  foldJobResponses: () => foldJobResponses,
107
107
  foldTaskRecords: () => foldTaskRecords,
108
108
  gdtiUri: () => gdtiUri,
109
+ generationFractionAt: () => generationFractionAt,
109
110
  graiUri: () => graiUri,
110
111
  hierarchyOf: () => hierarchyOf,
111
112
  inWorkCalendar: () => inWorkCalendar,
@@ -1757,7 +1758,28 @@ var EMS_PROPERTY = {
1757
1758
  *
1758
1759
  * 이것은 상태의 씨앗이지 계측이 아니다. 그래서 계측이 들어오는 순간 그것이 이긴다.
1759
1760
  */
1760
- initialSoc: "storage.initialSoc"
1761
+ initialSoc: "storage.initialSoc",
1762
+ /*
1763
+ * ── 발전 — 태양광이 서 있기만 하던 자리 (2026-08-18) ────────────────────────
1764
+ *
1765
+ * 계통도에 태양광을 그려 놓고 시뮬에서는 한 톨도 만들지 못했다. 미러는 원천이 발전량을 보내 주지만,
1766
+ * 시뮬 트윈에는 그것을 만들 근거가 없었다 — 그래서 「태양광을 늘리면 피크가 얼마나 내려가나」를 물을 수
1767
+ * 없었다(what-if 의 값이 절반만 성립했다).
1768
+ *
1769
+ * **형상을 우리가 지어내지 않는다.** 위도·계절·날씨로 곡선을 만들면 그 수가 어디서 왔는지 아무도
1770
+ * 설명할 수 없고, 흐린 날 현장의 실적과 어긋난다. 그래서 현장이 **하루 형상을 적는다**: 24개 비율
1771
+ * (0~1)이면 그것이 그 현장의 곡선이다(측정한 형상을 그대로 붙일 수 있다).
1772
+ *
1773
+ * 정격만 있고 형상이 없으면 발전하지 않는다 — 하루 종일 정격으로 발전하는 태양광은 없다.
1774
+ */
1775
+ /** 발전 정격(kW) — 맑은 정오의 최대 출력. */
1776
+ genRatedKW: "generation.ratedKW",
1777
+ /**
1778
+ * 하루 형상 — 쉼표로 나눈 **24개 비율**(0~1). 시각(현장 시간대)의 정격 대비 출력이다.
1779
+ *
1780
+ * 예: `0,0,0,0,0,0,0.05,0.2,0.45,0.7,0.9,1,1,0.95,0.8,0.6,0.35,0.12,0.02,0,0,0,0,0`
1781
+ */
1782
+ genDailyProfile: "generation.dailyProfile"
1761
1783
  };
1762
1784
  var EMS_PROPERTY_SPEC = {
1763
1785
  [EMS_PROPERTY.contractKW]: { uom: "kW", dataType: "xs:double", note: "contracted power at the metering point, in kW." },
@@ -1771,8 +1793,22 @@ var EMS_PROPERTY_SPEC = {
1771
1793
  [EMS_PROPERTY.maxDischargeKW]: { uom: "kW", dataType: "xs:double", note: "inverter limit on discharge rate, in kW." },
1772
1794
  /* 퍼센트다 — 0.8 은 0.8% 이고 80% 가 아니다. 이 한 줄이 없어서 배터리가 조용히 비어 있었다. */
1773
1795
  [EMS_PROPERTY.reserveSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "reserve state of charge as a percentage 0-100 (20 means 20%), never discharged below." },
1774
- [EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." }
1796
+ [EMS_PROPERTY.initialSoc]: { uom: "%", dataType: "xs:double", range: [0, 100], note: "starting state of charge as a percentage 0-100 (80 means 80%, not 0.8)." },
1797
+ [EMS_PROPERTY.genRatedKW]: { uom: "kW", dataType: "xs:double", note: "rated generation output at clear-sky noon, in kW." },
1798
+ [EMS_PROPERTY.genDailyProfile]: {
1799
+ dataType: "xs:string",
1800
+ note: "daily shape as 24 comma-separated fractions of rated output (0-1), one per hour of local time \u2014 the site declares its own curve; we do not invent one."
1801
+ }
1775
1802
  };
1803
+ function generationFractionAt(profile, hourOfDay) {
1804
+ const parts = String(profile ?? "").split(",").map((v2) => v2.trim()).filter((v2) => v2 !== "");
1805
+ if (parts.length !== 24) return void 0;
1806
+ const h = Math.floor(hourOfDay);
1807
+ if (!Number.isFinite(h) || h < 0 || h > 23) return void 0;
1808
+ const v = Number(parts[h]);
1809
+ if (!Number.isFinite(v) || v < 0 || v > 1) return void 0;
1810
+ return v;
1811
+ }
1776
1812
  var EMS_TYPES = [
1777
1813
  /* ── 자리: 전기적 구간 ─────────────────────────────────────────────────── */
1778
1814
  {
@@ -6383,6 +6419,7 @@ var EmsKernel = class extends FlowEngine {
6383
6419
  }
6384
6420
  }
6385
6421
  if (!counted) return;
6422
+ this.deriveGeneration(atMs);
6386
6423
  let generated = 0;
6387
6424
  let discharged = 0;
6388
6425
  let charged = 0;
@@ -6409,6 +6446,38 @@ var EmsKernel = class extends FlowEngine {
6409
6446
  this.revision++;
6410
6447
  this.judgeOpenWindow(atMs);
6411
6448
  }
6449
+ /**
6450
+ * 선언된 발전을 만든다 — **형상은 현장이 적는다.**
6451
+ *
6452
+ * 정격(`generation.ratedKW`)과 하루 형상(`generation.dailyProfile`)을 **둘 다** 선언한 설비만 발전한다:
6453
+ * 하루 종일 정격으로 발전하는 태양광은 없으므로, 정격만 있는 선언으로 발전을 만들면 그 값은 거짓이다.
6454
+ *
6455
+ * 값은 **만든 것**이라고 사실에 밝힌다(`derived: true`) — 저널을 읽는 쪽이 계측과 구별할 수 있어야 한다.
6456
+ * 시각은 현장 시간대로 읽는다(모델이 시간대를 말해 준다 — 교대 판정과 같은 기준).
6457
+ */
6458
+ deriveGeneration(atMs) {
6459
+ for (const e of this.boardDef?.equipment ?? []) {
6460
+ const rated = this.numberProperty(e, EMS_PROPERTY.genRatedKW);
6461
+ if (rated === void 0 || !(rated > 0)) continue;
6462
+ const profile = this.stringProperty(e, EMS_PROPERTY.genDailyProfile);
6463
+ const hour = Math.floor(minuteOfDayAt(atMs, this.boardDef?.utcOffsetMinutes) / 60);
6464
+ const fraction = generationFractionAt(profile, hour);
6465
+ const state = this.equipment.get(e.id);
6466
+ if (!state) continue;
6467
+ if (fraction === void 0) {
6468
+ if (Number(state.generatedKW) > 0) {
6469
+ ;
6470
+ state.generatedKW = 0;
6471
+ this.emitEnergyEquipment(e.id, atMs, { generatedKW: 0 });
6472
+ }
6473
+ continue;
6474
+ }
6475
+ const kW = rated * fraction;
6476
+ const before = Number(state.generatedKW);
6477
+ state.generatedKW = kW;
6478
+ if (!(Number.isFinite(before) && Math.abs(before - kW) < 1e-9)) this.emitEnergyEquipment(e.id, atMs, { generatedKW: kW });
6479
+ }
6480
+ }
6412
6481
  /** 이 설비가 방전 정책을 **선언했나** — 선언한 것의 방전은 정책이 매 틱 다시 정한다. */
6413
6482
  hasDispatchPolicy(equipmentId) {
6414
6483
  for (const e of this.boardDef?.equipment ?? []) {
@@ -6492,6 +6561,15 @@ var EmsKernel = class extends FlowEngine {
6492
6561
  }
6493
6562
  return void 0;
6494
6563
  }
6564
+ /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
6565
+ stringProperty(resource, id) {
6566
+ for (const p of resource?.properties ?? []) {
6567
+ if (p?.id !== id) continue;
6568
+ const v = String(p.value ?? "").trim();
6569
+ if (v) return v;
6570
+ }
6571
+ return void 0;
6572
+ }
6495
6573
  tick(dtMs) {
6496
6574
  super.tick(dtMs);
6497
6575
  const at = this.nowMs();
@@ -6974,6 +7052,7 @@ function retiredVocabularyIn(line) {
6974
7052
  foldJobResponses,
6975
7053
  foldTaskRecords,
6976
7054
  gdtiUri,
7055
+ generationFractionAt,
6977
7056
  graiUri,
6978
7057
  hierarchyOf,
6979
7058
  inWorkCalendar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.21",
3
+ "version": "0.7.22",
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": {