Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/environment

src/forecast-history.ts

389 lines · 12.2 KB · TypeScript
import { packageStorage } from 'kody:runtime'

/**
 * Package-storage key for the forecast history cache. This lived in the
 * `environmentForecastHistory` user value until 2026-07-27; values are now
 * capped at 64 KiB (oversized D1 rows made production backups
 * un-restorable), and this cache regularly exceeded that.
 */
const HISTORY_STORAGE_KEY = 'forecastHistory'
const HISTORY_RETENTION_DAYS = 90
const HISTORY_MAX_RECORDS = 500
const DEFAULT_RETRY_ATTEMPTS = 4
const DEFAULT_RETRY_BASE_DELAY_MS = 500
const DEFAULT_FORECAST_DAYS = 3
const DEFAULT_SEASONAL_MEAN_F = 60
const DEFAULT_SEASONAL_AMPLITUDE_F = 25
const DEFAULT_SEASONAL_PEAK_DAY = 200
const COORDINATE_TOLERANCE_DEG = 0.01

export type RecordForecastSeriesParams = {
	latitude: number
	longitude: number
	series: Array<{ date: string; highF?: number | null; lowF?: number | null }>
	source?: string
}

export type RecentForecastRecordParams = {
	latitude: number
	longitude: number
	date: string
}

export type ForecastHighParams = {
	latitude: number
	longitude: number
	date: string
	units?: 'metric' | 'imperial'
	timezone?: string
	allowHistory?: boolean
	allowSeasonal?: boolean
	seasonal?: { mean?: number; amplitude?: number; peakDay?: number }
	live?: { forecastDays?: number; attempts?: number; baseDelayMs?: number }
}

function dayOfYear(dateText: string): number | null {
	const pieces = String(dateText)
		.split('-')
		.map((value) => Number(value))
	if (pieces.length !== 3) return null
	const [year, month, day] = pieces
	if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
		return null
	}
	const start = Date.UTC(year, 0, 1)
	const cur = Date.UTC(year, month - 1, day)
	return Math.floor((cur - start) / 864e5) + 1
}

function roundCoordinate(value: unknown): number | null {
	const num = Number(value)
	if (!Number.isFinite(num)) return null
	return Math.round(num * 1e4) / 1e4
}

function fahrenheitToCelsius(value: unknown): number | null {
	if (typeof value !== 'number' || !Number.isFinite(value)) return null
	return Math.round(((value - 32) * 5) / 9 * 10) / 10
}

async function readHistory(): Promise<{
	records: any[]
	updatedAt: string | null
}> {
	try {
		const parsed = (await packageStorage().get(HISTORY_STORAGE_KEY)) as any
		if (!parsed || typeof parsed !== 'object') {
			return { records: [], updatedAt: null }
		}
		const records = Array.isArray(parsed.records) ? parsed.records : []
		return { records, updatedAt: parsed.updatedAt || null }
	} catch {
		return { records: [], updatedAt: null }
	}
}

async function writeHistory(history: { records: any[]; updatedAt: string | null }) {
	await packageStorage().set(HISTORY_STORAGE_KEY, history)
}

function pruneRecords(records: any[]) {
	const retainAfter = Date.now() - HISTORY_RETENTION_DAYS * 864e5
	const filtered = []
	for (const record of records) {
		if (!record || typeof record !== 'object') continue
		const recordedAt = Date.parse(record.recordedAt || '')
		if (!Number.isFinite(recordedAt)) continue
		if (recordedAt < retainAfter) continue
		filtered.push(record)
	}
	filtered.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
	return filtered.slice(0, HISTORY_MAX_RECORDS)
}

function findBestRecord(
	records: any[],
	latitude: unknown,
	longitude: unknown,
	date: unknown,
) {
	const lat = roundCoordinate(latitude)
	const lon = roundCoordinate(longitude)
	if (lat == null || lon == null) return null
	let best = null
	let bestRecorded = -Infinity
	for (const record of records) {
		if (!record || typeof record !== 'object') continue
		if (record.date !== date) continue
		if (Math.abs(Number(record.lat) - lat) > COORDINATE_TOLERANCE_DEG) continue
		if (Math.abs(Number(record.lon) - lon) > COORDINATE_TOLERANCE_DEG) continue
		const recordedAt = Date.parse(record.recordedAt || '')
		if (!Number.isFinite(recordedAt)) continue
		if (recordedAt > bestRecorded) {
			bestRecorded = recordedAt
			best = record
		}
	}
	return best
}

async function fetchOpenMeteoForecastDailyHighsF(params: {
	latitude: number
	longitude: number
	timezone?: string
	forecastDays?: number
	attempts?: number
	baseDelayMs?: number
}) {
	const url =
		'https://api.open-meteo.com/v1/forecast?latitude=' +
		encodeURIComponent(String(params.latitude)) +
		'&longitude=' +
		encodeURIComponent(String(params.longitude)) +
		'&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=' +
		encodeURIComponent(params.timezone || 'auto') +
		'&forecast_days=' +
		encodeURIComponent(String(params.forecastDays || DEFAULT_FORECAST_DAYS))
	const attempts = Number.isFinite(params.attempts)
		? Math.max(1, Math.floor(params.attempts as number))
		: DEFAULT_RETRY_ATTEMPTS
	const baseDelayMs = Number.isFinite(params.baseDelayMs)
		? Math.max(0, params.baseDelayMs as number)
		: DEFAULT_RETRY_BASE_DELAY_MS
	let lastError: Error | null = null
	for (let attempt = 1; attempt <= attempts; attempt += 1) {
		try {
			const response = await fetch(url)
			if (!response.ok) {
				throw new Error(
					'Open-Meteo forecast request failed for ' +
						url +
						': ' +
						response.status,
				)
			}
			const data = await response.json()
			if (
				!data ||
				!data.daily ||
				!Array.isArray(data.daily.time) ||
				!Array.isArray(data.daily.temperature_2m_max)
			) {
				throw new Error('Unexpected Open-Meteo response shape.')
			}
			const dates = data.daily.time
			const highs = data.daily.temperature_2m_max
			const lows = Array.isArray(data.daily.temperature_2m_min)
				? data.daily.temperature_2m_min
				: []
			const series = []
			for (let i = 0; i < dates.length; i += 1) {
				const high = Number(highs[i])
				if (!Number.isFinite(high)) continue
				const low = lows[i] != null ? Number(lows[i]) : null
				series.push({
					date: dates[i],
					highF: high,
					lowF: typeof low === 'number' && Number.isFinite(low) ? low : null,
				})
			}
			return { series, attempts: attempt, error: null }
		} catch (error) {
			lastError = error instanceof Error ? error : new Error(String(error))
			if (attempt < attempts) {
				await new Promise((resolve) =>
					setTimeout(resolve, baseDelayMs * Math.pow(2, attempt - 1)),
				)
			}
		}
	}
	throw lastError
}

function defaultSeasonalHighF(
	date: string,
	opts?: { mean?: number; amplitude?: number; peakDay?: number },
): number | null {
	const day = dayOfYear(date)
	if (day == null) return null
	const mean = Number.isFinite(Number(opts && opts.mean))
		? Number(opts!.mean)
		: DEFAULT_SEASONAL_MEAN_F
	const amplitude = Number.isFinite(Number(opts && opts.amplitude))
		? Number(opts!.amplitude)
		: DEFAULT_SEASONAL_AMPLITUDE_F
	const peakDay = Number.isFinite(Number(opts && opts.peakDay))
		? Number(opts!.peakDay)
		: DEFAULT_SEASONAL_PEAK_DAY
	return (
		Math.round(
			(mean + amplitude * Math.cos((2 * Math.PI * (day - peakDay)) / 365)) * 10,
		) / 10
	)
}

/** Persist a forecast series into the durable history value for later fallback reads. */
export async function recordForecastSeries(
	params: RecordForecastSeriesParams = {} as RecordForecastSeriesParams,
): Promise<{ written: number; totalRecords: number }> {
	const input = typeof params === 'object' && params ? params : ({} as any)
	const lat = roundCoordinate(input.latitude)
	const lon = roundCoordinate(input.longitude)
	const series = Array.isArray(input.series) ? input.series : []
	if (lat == null || lon == null || series.length === 0) {
		return { written: 0, totalRecords: 0 }
	}
	const history = await readHistory()
	const recordedAt = new Date().toISOString()
	const source =
		typeof input.source === 'string' && input.source ? input.source : 'open-meteo'
	let written = 0
	for (const point of series) {
		if (!point || typeof point !== 'object' || typeof point.date !== 'string') {
			continue
		}
		const highF =
			typeof point.highF === 'number' && Number.isFinite(point.highF)
				? point.highF
				: null
		const lowF =
			typeof point.lowF === 'number' && Number.isFinite(point.lowF)
				? point.lowF
				: null
		if (highF == null && lowF == null) continue
		history.records.push({
			lat,
			lon,
			date: point.date,
			highF,
			lowF,
			recordedAt,
			source,
		})
		written += 1
	}
	history.records = pruneRecords(history.records)
	history.updatedAt = recordedAt
	await writeHistory(history)
	return { written, totalRecords: history.records.length }
}

/** Read the most recent stored forecast record for a coordinate and date. */
export async function getRecentForecastRecord(
	params: RecentForecastRecordParams = {} as RecentForecastRecordParams,
): Promise<{ match: Record<string, unknown> | null; totalRecords: number }> {
	const input = typeof params === 'object' && params ? params : ({} as any)
	const history = await readHistory()
	const match = findBestRecord(
		history.records,
		input.latitude,
		input.longitude,
		input.date,
	)
	return { match, totalRecords: history.records.length }
}

/** Forecast daily high with live, history, and seasonal-estimate fallbacks. */
export async function getForecastHigh(
	params: ForecastHighParams = {} as ForecastHighParams,
) {
	const input = typeof params === 'object' && params ? params : ({} as any)
	const latitude = Number(input.latitude)
	const longitude = Number(input.longitude)
	if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
		throw new Error(
			'forecast-history.getForecastHigh requires numeric latitude and longitude.',
		)
	}
	const date = typeof input.date === 'string' && input.date ? input.date : null
	if (!date) {
		throw new Error(
			'forecast-history.getForecastHigh requires date in YYYY-MM-DD form.',
		)
	}
	const units = input.units === 'metric' ? 'metric' : 'imperial'
	const seasonal = (typeof input.seasonal === 'object' && input.seasonal) || {}
	const liveOptions = (typeof input.live === 'object' && input.live) || {}
	const allowHistory = input.allowHistory !== false
	const allowSeasonal = input.allowSeasonal !== false
	function toUnits(highF: number): number | null {
		if (units === 'metric') return fahrenheitToCelsius(highF)
		return highF
	}
	let liveError: Error | null = null
	let liveAttempts: number | null = null
	try {
		const live = await fetchOpenMeteoForecastDailyHighsF({
			latitude,
			longitude,
			timezone: input.timezone,
			forecastDays: liveOptions.forecastDays,
			attempts: liveOptions.attempts,
			baseDelayMs: liveOptions.baseDelayMs,
		})
		liveAttempts = live.attempts
		try {
			await recordForecastSeries({ latitude, longitude, series: live.series })
		} catch {
			// History writes are best-effort.
		}
		const point = live.series.find((p) => p.date === date)
		if (point && typeof point.highF === 'number' && Number.isFinite(point.highF)) {
			return {
				value: toUnits(point.highF),
				units,
				date,
				source: 'open-meteo',
				recordedAt: new Date().toISOString(),
				attempts: liveAttempts,
				fallbackError: null as string | null,
			}
		}
		liveError = new Error('Open-Meteo response did not include date ' + date)
	} catch (error) {
		liveError = error instanceof Error ? error : new Error(String(error))
	}
	if (allowHistory) {
		const recent = await getRecentForecastRecord({ latitude, longitude, date })
		const match = recent.match as any
		if (match && typeof match.highF === 'number' && Number.isFinite(match.highF)) {
			const recordedAt = match.recordedAt
			const ageMs = Date.now() - Date.parse(recordedAt || '')
			return {
				value: toUnits(match.highF),
				units,
				date,
				source: 'history',
				recordedAt,
				ageHours: Number.isFinite(ageMs) ? Math.round(ageMs / 36e5) : null,
				fallbackError: liveError ? liveError.message : null,
			}
		}
	}
	if (allowSeasonal) {
		const seasonalHighF = defaultSeasonalHighF(date, seasonal)
		if (typeof seasonalHighF === 'number' && Number.isFinite(seasonalHighF)) {
			return {
				value: toUnits(seasonalHighF),
				units,
				date,
				source: 'seasonal-estimate',
				seasonal: {
					mean: Number.isFinite(Number(seasonal.mean))
						? Number(seasonal.mean)
						: DEFAULT_SEASONAL_MEAN_F,
					amplitude: Number.isFinite(Number(seasonal.amplitude))
						? Number(seasonal.amplitude)
						: DEFAULT_SEASONAL_AMPLITUDE_F,
					peakDay: Number.isFinite(Number(seasonal.peakDay))
						? Number(seasonal.peakDay)
						: DEFAULT_SEASONAL_PEAK_DAY,
				},
				fallbackError: liveError ? liveError.message : null,
			}
		}
	}
	throw liveError || new Error('Forecast unavailable for ' + date)
}

export default getForecastHigh