@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0

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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Forecasting (pure functions, no I/O).
3
+ *
4
+ * - Holt-Winters additive with weekly seasonality (m = 7) for daily series with ≥ 21 points;
5
+ * - otherwise Holt's linear trend.
6
+ * Parameters are fitted by a small grid search minimising the one-step-ahead SSE.
7
+ * Prediction intervals: residual stddev × 1.96 × sqrt(h).
8
+ */
9
+
10
+ const GRID = [0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9]
11
+ const GRID_TREND = [0.01, 0.05, 0.1, 0.2, 0.3]
12
+ const GRID_SEASON = [0.05, 0.1, 0.2, 0.3, 0.5]
13
+
14
+ /** Holt's linear trend: one-step fitted values, final level/trend. */
15
+ export function holtFit(y, alpha, beta) {
16
+ const n = y.length
17
+ if (n < 2) return { fitted: y.map(() => null), level: y[0] ?? 0, trend: 0, sse: 0, count: 0 }
18
+ let level = y[0]
19
+ let trend = y[1] - y[0]
20
+ const fitted = [null]
21
+ let sse = 0
22
+ let count = 0
23
+ for (let t = 1; t < n; t++) {
24
+ const f = level + trend
25
+ fitted.push(f)
26
+ if (t >= 2) { sse += (y[t] - f) ** 2; count++ }
27
+ const prev = level
28
+ level = alpha * y[t] + (1 - alpha) * (level + trend)
29
+ trend = beta * (level - prev) + (1 - beta) * trend
30
+ }
31
+ return { fitted, level, trend, sse, count }
32
+ }
33
+
34
+ /** Holt-Winters additive with season length m (needs ≥ 2 m points). */
35
+ export function holtWintersFit(y, alpha, beta, gamma, m = 7) {
36
+ const n = y.length
37
+ const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length
38
+ const s1 = mean(y.slice(0, m))
39
+ const s2 = mean(y.slice(m, 2 * m))
40
+ let level = s1
41
+ let trend = (s2 - s1) / m
42
+ // Initial seasonal indices: average deviation from each full season's mean.
43
+ const seasons = Math.floor(n / m)
44
+ const season = new Array(m).fill(0)
45
+ for (let k = 0; k < seasons; k++) {
46
+ const mk = mean(y.slice(k * m, (k + 1) * m))
47
+ for (let i = 0; i < m; i++) season[i] += (y[k * m + i] - mk) / seasons
48
+ }
49
+ // Level at the end of the first season: start the recursion at t = m.
50
+ level = s1 + trend * (m - 1) / 2
51
+ const fitted = new Array(m).fill(null)
52
+ let sse = 0
53
+ let count = 0
54
+ for (let t = m; t < n; t++) {
55
+ const si = t % m
56
+ const f = level + trend + season[si]
57
+ fitted.push(f)
58
+ sse += (y[t] - f) ** 2
59
+ count++
60
+ const prev = level
61
+ level = alpha * (y[t] - season[si]) + (1 - alpha) * (level + trend)
62
+ trend = beta * (level - prev) + (1 - beta) * trend
63
+ season[si] = gamma * (y[t] - level) + (1 - gamma) * season[si]
64
+ }
65
+ return { fitted, level, trend, season, sse, count, n, m }
66
+ }
67
+
68
+ /**
69
+ * Fit and forecast `horizon` steps ahead.
70
+ * @param {number[]} values evenly spaced observations (no nulls)
71
+ * @param {{ horizon?: number, seasonal?: boolean, period?: number }} opts seasonal: allow weekly seasonality
72
+ * @returns {{ method, params, fitted, forecast: { h, value, lower, upper }[], sigma }}
73
+ */
74
+ export function forecastSeries(values, { horizon = 7, seasonal = true, period = 7 } = {}) {
75
+ const y = values.map(Number)
76
+ if (y.length < 3 || y.some(v => !Number.isFinite(v))) throw new Error('至少需要 3 个有效数值才能预测')
77
+ const H = Math.max(1, Math.min(365, Math.floor(horizon)))
78
+ const useHW = seasonal && y.length >= 3 * period
79
+ let best
80
+ if (useHW) {
81
+ for (const a of GRID) for (const b of GRID_TREND) for (const g of GRID_SEASON) {
82
+ const fit = holtWintersFit(y, a, b, g, period)
83
+ if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b, gamma: g } }
84
+ }
85
+ } else {
86
+ for (const a of GRID) for (const b of GRID_TREND) {
87
+ const fit = holtFit(y, a, b)
88
+ if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b } }
89
+ }
90
+ }
91
+ const { fit, params } = best
92
+ const sigma = fit.count > 1 ? Math.sqrt(fit.sse / (fit.count - 1)) : 0
93
+ const forecast = []
94
+ for (let h = 1; h <= H; h++) {
95
+ const value = useHW
96
+ ? fit.level + h * fit.trend + fit.season[(y.length + h - 1) % period]
97
+ : fit.level + h * fit.trend
98
+ const w = 1.96 * sigma * Math.sqrt(h)
99
+ forecast.push({ h, value, lower: value - w, upper: value + w })
100
+ }
101
+ return { method: useHW ? 'holt-winters' : 'holt', params, fitted: fit.fitted, forecast, sigma }
102
+ }
103
+
104
+ // ------------------------------------------------------------ time axis
105
+
106
+ const DAY = 86400000
107
+ const pad = (n) => String(n).padStart(2, '0')
108
+ const fmtDay = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
109
+
110
+ /** Parse a time label to { kind, ms } (UTC-based arithmetic, display unchanged). */
111
+ export function parseTimeLabel(v) {
112
+ const s = String(v ?? '').trim()
113
+ let m
114
+ // Dates serialized as midnight ISO timestamps (driver Date → ISO) are days.
115
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})T00:00:00(?:\.0+)?Z?$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
116
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(s))) return { kind: 'hour', ms: Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) }
117
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
118
+ if ((m = /^(\d{4})-(\d{2})$/.exec(s))) return { kind: 'month', ms: Date.UTC(+m[1], +m[2] - 1, 1) }
119
+ if ((m = /^(\d{4})-W(\d{2})$/.exec(s))) return { kind: 'week', ms: Date.UTC(+m[1], 0, 1) + (+m[2]) * 7 * DAY }
120
+ if ((m = /^(\d{4})$/.exec(s))) return { kind: 'year', ms: Date.UTC(+m[1], 0, 1) }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * Turn [{ x, y }] (sorted or not) into an evenly spaced series. Daily series get missing days
126
+ * filled with 0 (metrics like counts/sums have no row on days without data).
127
+ * Returns { kind, xs, ys, filled }.
128
+ */
129
+ export function regularize(points) {
130
+ const parsed = points.map(p => ({ ...p, t: parseTimeLabel(p.x) })).filter(p => p.y !== null && p.y !== undefined && Number.isFinite(Number(p.y)))
131
+ const timed = parsed.length >= 2 && parsed.every(p => p.t && p.t.kind === parsed[0].t.kind)
132
+ if (!timed) return { kind: 'index', xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
133
+ parsed.sort((a, b) => a.t.ms - b.t.ms)
134
+ const kind = parsed[0].t.kind
135
+ if (kind !== 'day') return { kind, xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
136
+ const byDay = new Map()
137
+ for (const p of parsed) byDay.set(p.t.ms, (byDay.get(p.t.ms) ?? 0) + Number(p.y))
138
+ const xs = []
139
+ const ys = []
140
+ let filled = 0
141
+ for (let ms = parsed[0].t.ms; ms <= parsed.at(-1).t.ms; ms += DAY) {
142
+ xs.push(fmtDay(new Date(ms)))
143
+ if (byDay.has(ms)) ys.push(byDay.get(ms)); else { ys.push(0); filled++ }
144
+ }
145
+ return { kind, xs, ys, filled }
146
+ }
147
+
148
+ /** Labels for the next `horizon` steps after the last label. */
149
+ export function futureLabels(lastLabel, kind, horizon) {
150
+ const t = parseTimeLabel(lastLabel)
151
+ const out = []
152
+ for (let h = 1; h <= horizon; h++) {
153
+ if (!t || kind === 'index' || kind === 'week') { out.push(`+${h}`); continue }
154
+ const d = new Date(t.ms)
155
+ if (kind === 'day') out.push(fmtDay(new Date(t.ms + h * DAY)))
156
+ else if (kind === 'hour') { const x = new Date(t.ms + h * 3600000); out.push(`${fmtDay(x)} ${pad(x.getUTCHours())}:00`) }
157
+ else if (kind === 'month') { const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + h, 1)); out.push(`${x.getUTCFullYear()}-${pad(x.getUTCMonth() + 1)}`) }
158
+ else if (kind === 'year') out.push(String(d.getUTCFullYear() + h))
159
+ }
160
+ return out
161
+ }
162
+
163
+ /** Forecast a labelled series: [{ x, y }] → history + labelled forecast. */
164
+ export function forecastPoints(points, { horizon = 7 } = {}) {
165
+ const reg = regularize(points)
166
+ const res = forecastSeries(reg.ys, { horizon, seasonal: reg.kind === 'day' })
167
+ const labels = futureLabels(reg.xs.at(-1), reg.kind, res.forecast.length)
168
+ return {
169
+ method: res.method,
170
+ params: res.params,
171
+ sigma: res.sigma,
172
+ kind: reg.kind,
173
+ filled: reg.filled,
174
+ history: reg.xs.map((x, i) => ({ x, y: reg.ys[i] })),
175
+ forecast: res.forecast.map((f, i) => ({ ...f, x: labels[i] })),
176
+ }
177
+ }
178
+
179
+ // ------------------------------------------------------------ digest statistics
180
+
181
+ /**
182
+ * Daily comparison for the digest: `values` is a day-indexed array ending with the day
183
+ * of interest. Compares the last value with the mean / stddev of the 7 days before it
184
+ * and with the same weekday one week earlier.
185
+ */
186
+ export function dailyStats(values) {
187
+ const y = values.map(Number)
188
+ const cur = y.at(-1)
189
+ const prev = y.slice(-8, -1)
190
+ const avg = prev.length ? prev.reduce((s, v) => s + v, 0) / prev.length : null
191
+ const sd = prev.length > 1 ? Math.sqrt(prev.reduce((s, v) => s + (v - avg) ** 2, 0) / (prev.length - 1)) : null
192
+ const lastWeek = y.length >= 8 ? y.at(-8) : null
193
+ const pct = (a, b) => (b === null || b === undefined || b === 0 ? null : (a - b) / Math.abs(b))
194
+ return {
195
+ value: cur,
196
+ avg7: avg,
197
+ sd7: sd,
198
+ z: sd ? (cur - avg) / sd : null,
199
+ vsAvg: pct(cur, avg),
200
+ lastWeek,
201
+ vsLastWeek: pct(cur, lastWeek),
202
+ }
203
+ }
204
+
205
+ /** Is a daily comparison notable? |z| > zLimit or |Δ% vs 7-day avg| > pctLimit. */
206
+ export function isNotable(stats, { z = 2, pct = 0.3 } = {}) {
207
+ return (stats.z !== null && Math.abs(stats.z) > z) || (stats.vsAvg !== null && Math.abs(stats.vsAvg) > pct)
208
+ }
209
+
210
+ // ------------------------------------------------------------ goals
211
+
212
+ /**
213
+ * Period bounds (local calendar) containing `now`: { start, end } as Date (end exclusive).
214
+ */
215
+ export function periodBounds(period, now = new Date()) {
216
+ const y = now.getFullYear()
217
+ const m = now.getMonth()
218
+ if (period === 'year') return { start: new Date(y, 0, 1), end: new Date(y + 1, 0, 1) }
219
+ if (period === 'quarter') { const q = Math.floor(m / 3) * 3; return { start: new Date(y, q, 1), end: new Date(y, q + 3, 1) } }
220
+ return { start: new Date(y, m, 1), end: new Date(y, m + 1, 1) }
221
+ }
222
+
223
+ /**
224
+ * Run-rate projection. `daily`: [{ date: Date (local midnight), value }] for complete days
225
+ * from the period start up to yesterday. Remaining days (today .. end-1) are projected with
226
+ * the per-weekday mean when every weekday has a sample, else the overall daily mean.
227
+ * Returns { projected, weekdayAware, remainingDays } (projected null without data).
228
+ */
229
+ export function projectRunRate({ daily, actualComplete, today, end }) {
230
+ const remaining = []
231
+ for (let d = new Date(today); d < end; d.setDate(d.getDate() + 1)) remaining.push(new Date(d))
232
+ if (!daily.length) return { projected: null, weekdayAware: false, remainingDays: remaining.length }
233
+ const all = daily.reduce((s, d) => s + d.value, 0) / daily.length
234
+ const byWd = new Map()
235
+ for (const d of daily) { const w = d.date.getDay(); const e = byWd.get(w) ?? { s: 0, n: 0 }; e.s += d.value; e.n++; byWd.set(w, e) }
236
+ const weekdayAware = byWd.size === 7
237
+ let rest = 0
238
+ for (const d of remaining) rest += weekdayAware ? byWd.get(d.getDay()).s / byWd.get(d.getDay()).n : all
239
+ return { projected: actualComplete + rest, weekdayAware, remainingDays: remaining.length }
240
+ }
241
+
242
+ /** Goal status from actual-to-date and projection. */
243
+ export function goalStatus({ direction = 'up', target, actual, projected, ended = false }) {
244
+ if (direction === 'down') {
245
+ if (actual > target) return 'missed'
246
+ if (ended) return 'achieved'
247
+ return projected === null || projected <= target ? 'on-track' : 'at-risk'
248
+ }
249
+ if (actual >= target) return 'achieved'
250
+ if (ended) return 'missed'
251
+ return projected !== null && projected >= target ? 'on-track' : 'at-risk'
252
+ }