mirror of
https://github.com/kennethnym/aris.git
synced 2026-02-02 13:11:17 +00:00
feat: add @aris/source-weatherkit package
Implements FeedSource for WeatherKit API. Depends on location source, provides weather context for downstream sources, and produces weather feed items. Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
363
packages/aris-source-weatherkit/src/weather-source.ts
Normal file
363
packages/aris-source-weatherkit/src/weather-source.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user