@operato/twin-kernel 0.7.8 → 0.7.10

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.
@@ -171,6 +171,20 @@ export declare function energyIntensity(input: IntensityInput): IntensityResult;
171
171
  export interface WindowedEnergy {
172
172
  /** 그 범위의 전력량 — 셀 수 있는 구간이 하나도 없으면 `undefined`(0 이 아니다). */
173
173
  kWh?: number;
174
+ /**
175
+ * 그 범위의 **최대수요** — 구간 최대부하 중 가장 큰 값. 요금이 걸리는 수다.
176
+ *
177
+ * ── 왜 여기서 내나 (2026-08-17) ──────────────────────────────────────────
178
+ * 「이 범위」를 정하는 규칙(걸친 구간은 세지 않는다)이 이미 여기 있다. 최대수요를 소비처에서 따로
179
+ * 세면 그 경계 규칙이 두 벌이 되고, 전력량과 최대수요가 **서로 다른 구간 집합**을 말하게 된다 —
180
+ * 같은 창을 두고 두 수가 어긋나면 어느 쪽도 못 믿는다.
181
+ *
182
+ * 평균부하(`meanKW`)로 대신하지 않는다. 15분 평균 400kW 인 구간의 최대가 900kW 일 수 있고,
183
+ * 계약을 넘겼는지는 **최대**가 답한다. 구간이 최대를 싣고 오지 않으면 이 값은 없다(짐작하지 않는다).
184
+ */
185
+ peakKW?: number;
186
+ /** 그 최대수요가 난 구간의 시작 — 「언제였나」를 물으면 답할 수 있어야 한다. */
187
+ peakStartMs?: number;
174
188
  /**
175
189
  * 어떻게 얻었나 — **파생임을 숨기지 않는다.**
176
190
  * · `mean-kw` — 구간 평균부하 × 구간 길이. 표본의 평균이므로 **추정**이다.
@@ -202,6 +216,7 @@ export declare function energyOfWindows(windows: readonly {
202
216
  startMs: number;
203
217
  endMs: number;
204
218
  meanKW?: number;
219
+ maxKW?: number;
205
220
  }[], range?: {
206
221
  startMs: number;
207
222
  endMs: number;
@@ -203,9 +203,20 @@ export function energyOfWindows(windows, range) {
203
203
  let skipped = 0;
204
204
  let first;
205
205
  let last;
206
+ let peakKW;
207
+ let peakStartMs;
206
208
  for (const w of windows ?? []) {
207
209
  if (!inRange(w))
208
210
  continue;
211
+ /*
212
+ * 최대수요는 전력량과 **따로 센다.** 평균을 못 읽어 전력량에서 빠진 구간도 최대는 실려 올 수
213
+ * 있고(그 반대도 있다), 하나가 없다고 다른 하나를 버리면 있는 사실을 잃는다.
214
+ */
215
+ const max = Number(w.maxKW);
216
+ if (Number.isFinite(max) && (peakKW === undefined || max > peakKW)) {
217
+ peakKW = max;
218
+ peakStartMs = Number(w.startMs);
219
+ }
209
220
  const mean = Number(w.meanKW);
210
221
  const hours = (Number(w.endMs) - Number(w.startMs)) / 3_600_000;
211
222
  if (!Number.isFinite(mean) || !Number.isFinite(hours) || hours <= 0) {
@@ -221,6 +232,7 @@ export function energyOfWindows(windows, range) {
221
232
  }
222
233
  return {
223
234
  ...(counted > 0 ? { kWh } : {}),
235
+ ...(peakKW !== undefined ? { peakKW, peakStartMs } : {}),
224
236
  basis: 'mean-kw',
225
237
  counted,
226
238
  skipped,
@@ -35,3 +35,22 @@ export interface MonteCarloOptions {
35
35
  * run 마다 fork(현재 보존) + scenario.load(seed+i)(미래 변주) + horizon 까지 구동. 원본 무간섭.
36
36
  */
37
37
  export declare function monteCarloForecast(twin: ForecastTwin, opts: MonteCarloOptions): MonteCarloResult;
38
+ /**
39
+ * 같은 예측을, **회차 사이에 자리를 내주면서** 계산한다.
40
+ *
41
+ * ── 왜 필요한가 (2026-08-17 실측) ───────────────────────────────────────────
42
+ * 이 예측은 호출한 쪽의 이벤트 루프에서 **동기로** 돈다. 실측으로 트윈 하나의 예측 한 번이
43
+ * 30회차 × 300틱 = 9,000틱, 한 틱 3.2ms → **28.5초**였다. 그동안 그 프로세스의 라이브 트윈도,
44
+ * HTTP 도, 구독도 전부 멈춘다 — 예측 한 번에 시스템 전체가 정지한다.
45
+ *
46
+ * 총 시간은 이 함수로 줄지 않는다(그건 틱 수를 줄이는 다른 일이다). 줄어드는 것은 **한 번에
47
+ * 붙잡는 시간**이다. 회차 하나가 끝날 때마다 자리를 내주면 그 사이에 틱과 요청이 지나간다.
48
+ *
49
+ * 양보 방법은 **호출자가 준다.** 커널은 실행 환경을 모른다(`setImmediate` 는 서버 런타임의 것이다).
50
+ * 주지 않으면 마이크로태스크로 떨어지는데, 그것은 I/O 에 자리를 내주지 못하므로 **얼어붙는 증상은
51
+ * 그대로**다 — 그래서 기본값에 기대지 말고 호출자가 명시하는 것이 맞다.
52
+ */
53
+ export declare function monteCarloForecastAsync(twin: ForecastTwin, opts: MonteCarloOptions & {
54
+ yieldFn?: () => Promise<void>;
55
+ yieldEveryTicks?: number;
56
+ }): Promise<MonteCarloResult>;
package/dist/forecast.js CHANGED
@@ -18,14 +18,9 @@ function clockOf(twin) {
18
18
  */
19
19
  export function monteCarloForecast(twin, opts) {
20
20
  const now = clockOf(twin);
21
- const step = opts.tickMs ?? 1000;
22
- const baseSeed = opts.scenario.seed ?? 1;
23
21
  const samples = [];
24
22
  for (let i = 0; i < opts.runs; i++) {
25
- const fc = twin.fork();
26
- fc.scenario.load({ ...opts.scenario, seed: baseSeed + i }); // 미래만 변주(현재 상태는 fork 로 보존)
27
- fc.scenario.start();
28
- const target = now + opts.horizonMs;
23
+ const { fc, step, target } = startRun(twin, opts, now, i);
29
24
  let guard = 0;
30
25
  while (clockOf(fc) < target && guard++ < 1_000_000)
31
26
  fc.tick(step);
@@ -33,6 +28,61 @@ export function monteCarloForecast(twin, opts) {
33
28
  }
34
29
  return summarize(opts.runs, samples);
35
30
  }
31
+ /**
32
+ * 한 회차의 **출발점** — fork(현재 보존) + 그 회차의 미래(seed 변주).
33
+ *
34
+ * 동기·비동기 두 진입점이 이것을 함께 쓴다. 구동 루프는 각자 쓰지만(하나는 중간에 await 한다),
35
+ * **표본이 무엇이 되는지를 정하는 규칙**(어디서 갈라져 어떤 seed 로 도는가)은 여기 한 곳이다.
36
+ * 그것이 갈라지면 같은 seed 가 다른 수를 내고, 그때는 어느 쪽이 옳은지 가릴 방법이 없다.
37
+ */
38
+ function startRun(twin, opts, nowMs, i) {
39
+ const fc = twin.fork();
40
+ fc.scenario.load({ ...opts.scenario, seed: (opts.scenario.seed ?? 1) + i }); // 미래만 변주(현재 상태는 fork 로 보존)
41
+ fc.scenario.start();
42
+ return { fc, step: opts.tickMs ?? 1000, target: nowMs + opts.horizonMs };
43
+ }
44
+ /**
45
+ * 같은 예측을, **회차 사이에 자리를 내주면서** 계산한다.
46
+ *
47
+ * ── 왜 필요한가 (2026-08-17 실측) ───────────────────────────────────────────
48
+ * 이 예측은 호출한 쪽의 이벤트 루프에서 **동기로** 돈다. 실측으로 트윈 하나의 예측 한 번이
49
+ * 30회차 × 300틱 = 9,000틱, 한 틱 3.2ms → **28.5초**였다. 그동안 그 프로세스의 라이브 트윈도,
50
+ * HTTP 도, 구독도 전부 멈춘다 — 예측 한 번에 시스템 전체가 정지한다.
51
+ *
52
+ * 총 시간은 이 함수로 줄지 않는다(그건 틱 수를 줄이는 다른 일이다). 줄어드는 것은 **한 번에
53
+ * 붙잡는 시간**이다. 회차 하나가 끝날 때마다 자리를 내주면 그 사이에 틱과 요청이 지나간다.
54
+ *
55
+ * 양보 방법은 **호출자가 준다.** 커널은 실행 환경을 모른다(`setImmediate` 는 서버 런타임의 것이다).
56
+ * 주지 않으면 마이크로태스크로 떨어지는데, 그것은 I/O 에 자리를 내주지 못하므로 **얼어붙는 증상은
57
+ * 그대로**다 — 그래서 기본값에 기대지 말고 호출자가 명시하는 것이 맞다.
58
+ */
59
+ export async function monteCarloForecastAsync(twin, opts) {
60
+ const now = clockOf(twin);
61
+ const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
62
+ /*
63
+ * **회차 사이만 내주면 부족하다.** 회차 하나가 300틱 × 3.2ms ≈ 1초라, 그동안 들어온 요청은 그만큼
64
+ * 기다린다(실측 응답 3초). 그래서 틱 몇 개마다도 끊는다 — 기본값은 라이브 틱 간격(1초)보다 훨씬
65
+ * 짧게 잡아, 트윈이 한 박자도 밀리지 않을 크기로 둔다.
66
+ */
67
+ const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
68
+ const samples = [];
69
+ for (let i = 0; i < opts.runs; i++) {
70
+ const { fc, step, target } = startRun(twin, opts, now, i);
71
+ let guard = 0;
72
+ let sinceYield = 0;
73
+ while (clockOf(fc) < target && guard++ < 1_000_000) {
74
+ fc.tick(step);
75
+ if (++sinceYield >= everyTicks) {
76
+ sinceYield = 0;
77
+ await yieldFn();
78
+ }
79
+ }
80
+ samples.push(opts.metric(fc.getSnapshot()));
81
+ if (i < opts.runs - 1)
82
+ await yieldFn();
83
+ }
84
+ return summarize(opts.runs, samples);
85
+ }
36
86
  function summarize(runs, samples) {
37
87
  const sorted = [...samples].sort((a, b) => a - b);
38
88
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
@@ -122,6 +122,7 @@ __export(index_exports, {
122
122
  meetsTests: () => meetsTests,
123
123
  minuteOfDayAt: () => minuteOfDayAt,
124
124
  monteCarloForecast: () => monteCarloForecast,
125
+ monteCarloForecastAsync: () => monteCarloForecastAsync,
125
126
  objectEvent: () => objectEvent,
126
127
  offCalendarAt: () => offCalendarAt,
127
128
  offCalendarReasonAt: () => offCalendarReasonAt,
@@ -730,20 +731,42 @@ function clockOf2(twin) {
730
731
  }
731
732
  function monteCarloForecast(twin, opts) {
732
733
  const now = clockOf2(twin);
733
- const step = opts.tickMs ?? 1e3;
734
- const baseSeed = opts.scenario.seed ?? 1;
735
734
  const samples = [];
736
735
  for (let i = 0; i < opts.runs; i++) {
737
- const fc = twin.fork();
738
- fc.scenario.load({ ...opts.scenario, seed: baseSeed + i });
739
- fc.scenario.start();
740
- const target = now + opts.horizonMs;
736
+ const { fc, step, target } = startRun(twin, opts, now, i);
741
737
  let guard = 0;
742
738
  while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
743
739
  samples.push(opts.metric(fc.getSnapshot()));
744
740
  }
745
741
  return summarize(opts.runs, samples);
746
742
  }
743
+ function startRun(twin, opts, nowMs, i) {
744
+ const fc = twin.fork();
745
+ fc.scenario.load({ ...opts.scenario, seed: (opts.scenario.seed ?? 1) + i });
746
+ fc.scenario.start();
747
+ return { fc, step: opts.tickMs ?? 1e3, target: nowMs + opts.horizonMs };
748
+ }
749
+ async function monteCarloForecastAsync(twin, opts) {
750
+ const now = clockOf2(twin);
751
+ const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
752
+ const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
753
+ const samples = [];
754
+ for (let i = 0; i < opts.runs; i++) {
755
+ const { fc, step, target } = startRun(twin, opts, now, i);
756
+ let guard = 0;
757
+ let sinceYield = 0;
758
+ while (clockOf2(fc) < target && guard++ < 1e6) {
759
+ fc.tick(step);
760
+ if (++sinceYield >= everyTicks) {
761
+ sinceYield = 0;
762
+ await yieldFn();
763
+ }
764
+ }
765
+ samples.push(opts.metric(fc.getSnapshot()));
766
+ if (i < opts.runs - 1) await yieldFn();
767
+ }
768
+ return summarize(opts.runs, samples);
769
+ }
747
770
  function summarize(runs, samples) {
748
771
  const sorted = [...samples].sort((a, b) => a - b);
749
772
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
@@ -6339,8 +6362,15 @@ function energyOfWindows(windows, range) {
6339
6362
  let skipped = 0;
6340
6363
  let first;
6341
6364
  let last;
6365
+ let peakKW;
6366
+ let peakStartMs;
6342
6367
  for (const w of windows ?? []) {
6343
6368
  if (!inRange(w)) continue;
6369
+ const max = Number(w.maxKW);
6370
+ if (Number.isFinite(max) && (peakKW === void 0 || max > peakKW)) {
6371
+ peakKW = max;
6372
+ peakStartMs = Number(w.startMs);
6373
+ }
6344
6374
  const mean = Number(w.meanKW);
6345
6375
  const hours = (Number(w.endMs) - Number(w.startMs)) / 36e5;
6346
6376
  if (!Number.isFinite(mean) || !Number.isFinite(hours) || hours <= 0) {
@@ -6354,6 +6384,7 @@ function energyOfWindows(windows, range) {
6354
6384
  }
6355
6385
  return {
6356
6386
  ...counted > 0 ? { kWh } : {},
6387
+ ...peakKW !== void 0 ? { peakKW, peakStartMs } : {},
6357
6388
  basis: "mean-kw",
6358
6389
  counted,
6359
6390
  skipped,
@@ -6508,6 +6539,7 @@ function retiredVocabularyIn(line) {
6508
6539
  meetsTests,
6509
6540
  minuteOfDayAt,
6510
6541
  monteCarloForecast,
6542
+ monteCarloForecastAsync,
6511
6543
  objectEvent,
6512
6544
  offCalendarAt,
6513
6545
  offCalendarReasonAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
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": {