mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
364 lines
9.7 KiB
TypeScript
364 lines
9.7 KiB
TypeScript
|
|
import type { Context, FeedSource } from "@aris/core"
|
||
|
|
|
||
|
|
import { contextValue } from "@aris/core"
|
||
|
|
import { LocationKey } from "@aris/source-location"
|
||
|
|
|
||
|
|
import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
|
||
|
|
import { WeatherKey, type Weather } from "./weather-context"
|
||
|
|
import {
|
||
|
|
DefaultWeatherKitClient,
|
||
|
|
type ConditionCode,
|
||
|
|
type CurrentWeather,
|
||
|
|
type DailyForecast,
|
||
|
|
type HourlyForecast,
|
||
|
|
type Severity,
|
||
|
|
type WeatherAlert,
|
||
|
|
type WeatherKitClient,
|
||
|
|
type WeatherKitCredentials,
|
||
|
|
} from "./weatherkit"
|
||
|
|
|
||
|
|
export const Units = {
|
||
|
|
metric: "metric",
|
||
|
|
imperial: "imperial",
|
||
|
|
} as const
|
||
|
|
|
||
|
|
export type Units = (typeof Units)[keyof typeof Units]
|
||
|
|
|
||
|
|
export interface WeatherSourceOptions {
|
||
|
|
credentials?: WeatherKitCredentials
|
||
|
|
client?: WeatherKitClient
|
||
|
|
/** Number of hourly forecasts to include (default: 12) */
|
||
|
|
hourlyLimit?: number
|
||
|
|
/** Number of daily forecasts to include (default: 7) */
|
||
|
|
dailyLimit?: number
|
||
|
|
/** Units for temperature and measurements (default: metric) */
|
||
|
|
units?: Units
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_HOURLY_LIMIT = 12
|
||
|
|
const DEFAULT_DAILY_LIMIT = 7
|
||
|
|
|
||
|
|
const BASE_PRIORITY = {
|
||
|
|
current: 0.5,
|
||
|
|
hourly: 0.3,
|
||
|
|
daily: 0.2,
|
||
|
|
alert: 0.7,
|
||
|
|
} as const
|
||
|
|
|
||
|
|
const SEVERE_CONDITIONS = new Set<ConditionCode>([
|
||
|
|
"SevereThunderstorm",
|
||
|
|
"Hurricane",
|
||
|
|
"Tornado",
|
||
|
|
"TropicalStorm",
|
||
|
|
"Blizzard",
|
||
|
|
"FreezingRain",
|
||
|
|
"Hail",
|
||
|
|
"Frigid",
|
||
|
|
"Hot",
|
||
|
|
])
|
||
|
|
|
||
|
|
const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
||
|
|
"Thunderstorm",
|
||
|
|
"IsolatedThunderstorms",
|
||
|
|
"ScatteredThunderstorms",
|
||
|
|
"HeavyRain",
|
||
|
|
"HeavySnow",
|
||
|
|
"FreezingDrizzle",
|
||
|
|
"BlowingSnow",
|
||
|
|
])
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A FeedSource that provides weather context and feed items using Apple WeatherKit.
|
||
|
|
*
|
||
|
|
* Depends on location source for coordinates. Provides simplified weather context
|
||
|
|
* for downstream sources and produces weather feed items (current, hourly, daily, alerts).
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* ```ts
|
||
|
|
* const weatherSource = new WeatherSource({
|
||
|
|
* credentials: {
|
||
|
|
* privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||
|
|
* keyId: process.env.WEATHERKIT_KEY_ID!,
|
||
|
|
* teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||
|
|
* serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||
|
|
* },
|
||
|
|
* units: Units.metric,
|
||
|
|
* })
|
||
|
|
*
|
||
|
|
* // Access weather context in downstream sources
|
||
|
|
* const weather = contextValue(context, WeatherKey)
|
||
|
|
* if (weather?.condition === "Rain") {
|
||
|
|
* // suggest umbrella
|
||
|
|
* }
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
||
|
|
readonly id = "weather"
|
||
|
|
readonly dependencies = ["location"]
|
||
|
|
|
||
|
|
private readonly client: WeatherKitClient
|
||
|
|
private readonly hourlyLimit: number
|
||
|
|
private readonly dailyLimit: number
|
||
|
|
private readonly units: Units
|
||
|
|
|
||
|
|
constructor(options: WeatherSourceOptions) {
|
||
|
|
if (!options.client && !options.credentials) {
|
||
|
|
throw new Error("Either client or credentials must be provided")
|
||
|
|
}
|
||
|
|
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
|
||
|
|
this.hourlyLimit = options.hourlyLimit ?? DEFAULT_HOURLY_LIMIT
|
||
|
|
this.dailyLimit = options.dailyLimit ?? DEFAULT_DAILY_LIMIT
|
||
|
|
this.units = options.units ?? Units.metric
|
||
|
|
}
|
||
|
|
|
||
|
|
async fetchContext(context: Context): Promise<Partial<Context>> {
|
||
|
|
const location = contextValue(context, LocationKey)
|
||
|
|
if (!location) {
|
||
|
|
return {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await this.client.fetch({
|
||
|
|
lat: location.lat,
|
||
|
|
lng: location.lng,
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!response.currentWeather) {
|
||
|
|
return {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const weather: Weather = {
|
||
|
|
temperature: convertTemperature(response.currentWeather.temperature, this.units),
|
||
|
|
temperatureApparent: convertTemperature(
|
||
|
|
response.currentWeather.temperatureApparent,
|
||
|
|
this.units,
|
||
|
|
),
|
||
|
|
condition: response.currentWeather.conditionCode,
|
||
|
|
humidity: response.currentWeather.humidity,
|
||
|
|
uvIndex: response.currentWeather.uvIndex,
|
||
|
|
windSpeed: convertSpeed(response.currentWeather.windSpeed, this.units),
|
||
|
|
daylight: response.currentWeather.daylight,
|
||
|
|
}
|
||
|
|
|
||
|
|
return { [WeatherKey]: weather }
|
||
|
|
}
|
||
|
|
|
||
|
|
async fetchItems(context: Context): Promise<WeatherFeedItem[]> {
|
||
|
|
const location = contextValue(context, LocationKey)
|
||
|
|
if (!location) {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
const timestamp = context.time
|
||
|
|
|
||
|
|
const response = await this.client.fetch({
|
||
|
|
lat: location.lat,
|
||
|
|
lng: location.lng,
|
||
|
|
})
|
||
|
|
|
||
|
|
const items: WeatherFeedItem[] = []
|
||
|
|
|
||
|
|
if (response.currentWeather) {
|
||
|
|
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units))
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.forecastHourly?.hours) {
|
||
|
|
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
||
|
|
for (let i = 0; i < hours.length; i++) {
|
||
|
|
const hour = hours[i]
|
||
|
|
if (hour) {
|
||
|
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.forecastDaily?.days) {
|
||
|
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
||
|
|
for (let i = 0; i < days.length; i++) {
|
||
|
|
const day = days[i]
|
||
|
|
if (day) {
|
||
|
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.weatherAlerts?.alerts) {
|
||
|
|
for (const alert of response.weatherAlerts.alerts) {
|
||
|
|
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return items
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
|
||
|
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
||
|
|
return Math.min(1, basePriority + 0.3)
|
||
|
|
}
|
||
|
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
||
|
|
return Math.min(1, basePriority + 0.15)
|
||
|
|
}
|
||
|
|
return basePriority
|
||
|
|
}
|
||
|
|
|
||
|
|
function adjustPriorityForAlertSeverity(severity: Severity): number {
|
||
|
|
switch (severity) {
|
||
|
|
case "extreme":
|
||
|
|
return 1
|
||
|
|
case "severe":
|
||
|
|
return 0.9
|
||
|
|
case "moderate":
|
||
|
|
return 0.75
|
||
|
|
case "minor":
|
||
|
|
return BASE_PRIORITY.alert
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function convertTemperature(celsius: number, units: Units): number {
|
||
|
|
if (units === Units.imperial) {
|
||
|
|
return (celsius * 9) / 5 + 32
|
||
|
|
}
|
||
|
|
return celsius
|
||
|
|
}
|
||
|
|
|
||
|
|
function convertSpeed(kmh: number, units: Units): number {
|
||
|
|
if (units === Units.imperial) {
|
||
|
|
return kmh * 0.621371
|
||
|
|
}
|
||
|
|
return kmh
|
||
|
|
}
|
||
|
|
|
||
|
|
function convertDistance(km: number, units: Units): number {
|
||
|
|
if (units === Units.imperial) {
|
||
|
|
return km * 0.621371
|
||
|
|
}
|
||
|
|
return km
|
||
|
|
}
|
||
|
|
|
||
|
|
function convertPrecipitation(mm: number, units: Units): number {
|
||
|
|
if (units === Units.imperial) {
|
||
|
|
return mm * 0.0393701
|
||
|
|
}
|
||
|
|
return mm
|
||
|
|
}
|
||
|
|
|
||
|
|
function convertPressure(mb: number, units: Units): number {
|
||
|
|
if (units === Units.imperial) {
|
||
|
|
return mb * 0.02953
|
||
|
|
}
|
||
|
|
return mb
|
||
|
|
}
|
||
|
|
|
||
|
|
function createCurrentWeatherFeedItem(
|
||
|
|
current: CurrentWeather,
|
||
|
|
timestamp: Date,
|
||
|
|
units: Units,
|
||
|
|
): WeatherFeedItem {
|
||
|
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `weather-current-${timestamp.getTime()}`,
|
||
|
|
type: WeatherFeedItemType.current,
|
||
|
|
priority,
|
||
|
|
timestamp,
|
||
|
|
data: {
|
||
|
|
conditionCode: current.conditionCode,
|
||
|
|
daylight: current.daylight,
|
||
|
|
humidity: current.humidity,
|
||
|
|
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
|
||
|
|
pressure: convertPressure(current.pressure, units),
|
||
|
|
pressureTrend: current.pressureTrend,
|
||
|
|
temperature: convertTemperature(current.temperature, units),
|
||
|
|
temperatureApparent: convertTemperature(current.temperatureApparent, units),
|
||
|
|
uvIndex: current.uvIndex,
|
||
|
|
visibility: convertDistance(current.visibility, units),
|
||
|
|
windDirection: current.windDirection,
|
||
|
|
windGust: convertSpeed(current.windGust, units),
|
||
|
|
windSpeed: convertSpeed(current.windSpeed, units),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createHourlyWeatherFeedItem(
|
||
|
|
hourly: HourlyForecast,
|
||
|
|
index: number,
|
||
|
|
timestamp: Date,
|
||
|
|
units: Units,
|
||
|
|
): WeatherFeedItem {
|
||
|
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||
|
|
type: WeatherFeedItemType.hourly,
|
||
|
|
priority,
|
||
|
|
timestamp,
|
||
|
|
data: {
|
||
|
|
forecastTime: new Date(hourly.forecastStart),
|
||
|
|
conditionCode: hourly.conditionCode,
|
||
|
|
daylight: hourly.daylight,
|
||
|
|
humidity: hourly.humidity,
|
||
|
|
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
|
||
|
|
precipitationChance: hourly.precipitationChance,
|
||
|
|
precipitationType: hourly.precipitationType,
|
||
|
|
temperature: convertTemperature(hourly.temperature, units),
|
||
|
|
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
|
||
|
|
uvIndex: hourly.uvIndex,
|
||
|
|
windDirection: hourly.windDirection,
|
||
|
|
windGust: convertSpeed(hourly.windGust, units),
|
||
|
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createDailyWeatherFeedItem(
|
||
|
|
daily: DailyForecast,
|
||
|
|
index: number,
|
||
|
|
timestamp: Date,
|
||
|
|
units: Units,
|
||
|
|
): WeatherFeedItem {
|
||
|
|
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||
|
|
type: WeatherFeedItemType.daily,
|
||
|
|
priority,
|
||
|
|
timestamp,
|
||
|
|
data: {
|
||
|
|
forecastDate: new Date(daily.forecastStart),
|
||
|
|
conditionCode: daily.conditionCode,
|
||
|
|
maxUvIndex: daily.maxUvIndex,
|
||
|
|
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
|
||
|
|
precipitationChance: daily.precipitationChance,
|
||
|
|
precipitationType: daily.precipitationType,
|
||
|
|
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
|
||
|
|
sunrise: new Date(daily.sunrise),
|
||
|
|
sunset: new Date(daily.sunset),
|
||
|
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
||
|
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
||
|
|
const priority = adjustPriorityForAlertSeverity(alert.severity)
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `weather-alert-${alert.id}`,
|
||
|
|
type: WeatherFeedItemType.alert,
|
||
|
|
priority,
|
||
|
|
timestamp,
|
||
|
|
data: {
|
||
|
|
alertId: alert.id,
|
||
|
|
areaName: alert.areaName,
|
||
|
|
certainty: alert.certainty,
|
||
|
|
description: alert.description,
|
||
|
|
detailsUrl: alert.detailsUrl,
|
||
|
|
effectiveTime: new Date(alert.effectiveTime),
|
||
|
|
expireTime: new Date(alert.expireTime),
|
||
|
|
severity: alert.severity,
|
||
|
|
source: alert.source,
|
||
|
|
urgency: alert.urgency,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|