@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.
- package/dist/energy-attribution.d.ts +15 -0
- package/dist/energy-attribution.js +12 -0
- package/dist/forecast.d.ts +19 -0
- package/dist/forecast.js +56 -6
- package/dist-cjs/index.cjs +38 -6
- package/package.json +1 -1
|
@@ -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,
|
package/dist/forecast.d.ts
CHANGED
|
@@ -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
|
|
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))];
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -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
|
|
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