Skip to content

Commit f30d3bd

Browse files
committed
scale-up or linear (depending on available points)
1 parent bf5614b commit f30d3bd

2 files changed

Lines changed: 37 additions & 18 deletions

File tree

app/utils/chart-data-prediction.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ export function linearProject(pts: number[]): number | null {
9696
/**
9797
* Estimate the full-period value for a partially-complete last bucket.
9898
*
99-
* Fallback chain: linear projection → single-point copy → proportional scale-up.
99+
* Uses linear projection when enough complete lookback points are available
100+
* (`>= predictionPoints`), otherwise falls back to proportional scale-up.
100101
* Returns the raw last value when the period is already complete or prediction is disabled.
101102
*/
102103
export function extrapolateLastValue(params: {
@@ -115,9 +116,11 @@ export function extrapolateLastValue(params: {
115116
if (!(ratio > 0 && ratio < 1) || predictionPoints <= 0) return last
116117

117118
const lookback = series.slice(0, -1).slice(-predictionPoints)
118-
const projected = linearProject(lookback)
119-
if (projected !== null) return projected
120-
if (lookback.length === 1) return lookback[0]!
119+
120+
if (lookback.length >= predictionPoints) {
121+
const projected = linearProject(lookback)
122+
if (projected !== null) return projected
123+
}
121124

122125
const scaled = last / ratio
123126
return Number.isFinite(scaled) ? scaled : last

test/unit/app/utils/chart-data-prediction.spec.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,11 @@ describe('linearProject', () => {
123123

124124
describe('extrapolateLastValue', () => {
125125
it('returns raw last value when ratio >= 1 (complete bucket)', () => {
126-
const lastDateMs = Date.UTC(2025, 1, 1)
127126
const result = extrapolateLastValue({
128127
series: [100, 200, 150],
129128
granularity: 'monthly',
130-
lastDateMs,
131-
referenceMs: Date.UTC(2025, 2, 1), // bucket fully elapsed
129+
lastDateMs: Date.UTC(2025, 1, 1),
130+
referenceMs: Date.UTC(2025, 2, 1),
132131
predictionPoints: 4,
133132
})
134133
expect(result).toBe(150)
@@ -145,36 +144,53 @@ describe('extrapolateLastValue', () => {
145144
expect(result).toBe(50)
146145
})
147146

148-
it('uses linear projection when enough lookback points', () => {
147+
it('uses linear projection when lookback >= predictionPoints', () => {
148+
// 4 lookback points, predictionPoints = 4 → linear projection
149149
const result = extrapolateLastValue({
150-
series: [100, 200, 300, 50],
150+
series: [100, 200, 300, 400, 50],
151151
granularity: 'monthly',
152-
lastDateMs: Date.UTC(2025, 2, 1),
153-
referenceMs: Date.UTC(2025, 2, 15),
152+
lastDateMs: Date.UTC(2025, 3, 1),
153+
referenceMs: Date.UTC(2025, 3, 15),
154154
predictionPoints: 4,
155155
})
156-
expect(result).toBeCloseTo(400)
156+
expect(result).toBeCloseTo(500)
157157
})
158158

159-
it('copies single lookback point when only one available', () => {
159+
it('falls back to scale-up when lookback < predictionPoints', () => {
160+
// 2 lookback points but predictionPoints = 4 → scale-up
160161
const result = extrapolateLastValue({
161-
series: [100, 10],
162+
series: [100, 200, 50],
162163
granularity: 'monthly',
163164
lastDateMs: Date.UTC(2025, 2, 1),
164-
referenceMs: Date.UTC(2025, 2, 15),
165+
referenceMs: Date.UTC(2025, 2, 16),
165166
predictionPoints: 4,
166167
})
167-
expect(result).toBe(100)
168+
// ratio ≈ 15/31, scaled ≈ 50 / (15/31) ≈ 103.3
169+
expect(result).toBeGreaterThan(50)
170+
expect(result).toBeLessThan(200)
168171
})
169172

170-
it('falls back to proportional scale-up with no lookback', () => {
173+
it('falls back to scale-up with no lookback', () => {
171174
const result = extrapolateLastValue({
172175
series: [50],
173176
granularity: 'daily',
174177
lastDateMs: Date.UTC(2025, 2, 12),
175-
referenceMs: Date.UTC(2025, 2, 12, 12, 0), // half day
178+
referenceMs: Date.UTC(2025, 2, 12, 12, 0),
176179
predictionPoints: 4,
177180
})
178181
expect(result).toBeCloseTo(100)
179182
})
183+
184+
it('yearly with few points uses scale-up', () => {
185+
// 2 complete years + partial 2025, predictionPoints = 4 → scale-up
186+
const result = extrapolateLastValue({
187+
series: [1000, 2000, 500],
188+
granularity: 'yearly',
189+
lastDateMs: Date.UTC(2025, 0, 1),
190+
referenceMs: Date.UTC(2025, 2, 10),
191+
predictionPoints: 4,
192+
})
193+
// ~69 days into the year, ratio ≈ 0.19 → scaled ≈ 500 / 0.19 ≈ 2632
194+
expect(result).toBeGreaterThan(2000)
195+
})
180196
})

0 commit comments

Comments
 (0)