import { resolveDate } from './date.ts' import { fetchJsonWithRetry, type RetryOptions } from './open-meteo-client.ts' import { resolveWeatherLocation } from './weather-geocode.ts' export type AirQualityLookupParams = { location?: string latitude?: number longitude?: number label?: string date?: string hour?: number | null hourlyLimit?: number live?: RetryOptions } async function fetchAirQualityData(params: { latitude: number longitude: number date: string live?: RetryOptions }) { const input = typeof params === 'object' && params ? params : ({} as any) const latitude = Number(input.latitude) const longitude = Number(input.longitude) const date = String(input.date || '') const today = new Date().toISOString().slice(0, 10) const hourlyVars = [ 'us_aqi', 'european_aqi', 'pm2_5', 'pm10', 'carbon_monoxide', 'nitrogen_dioxide', 'sulphur_dioxide', 'ozone', ].join(',') const url = 'https://air-quality-api.open-meteo.com/v1/air-quality?latitude=' + encodeURIComponent(String(latitude)) + '&longitude=' + encodeURIComponent(String(longitude)) + '&hourly=' + encodeURIComponent(hourlyVars) + '&timezone=auto&start_date=' + encodeURIComponent(date) + '&end_date=' + encodeURIComponent(date) const live = await fetchJsonWithRetry(url, input.live || {}) const data = live.data if (!data.hourly || !Array.isArray(data.hourly.time)) { throw new Error('Unexpected air-quality response shape.') } return { data, source: 'open-meteo', attempts: live.attempts, isHistorical: date < today, isForecast: date > today, } } function maxFinite(values: unknown[]): number | null { let max: number | null = null for (const value of values) { const numeric = Number(value) if (!Number.isFinite(numeric)) continue if (max === null || numeric > max) max = numeric } return max } function averageFinite(values: unknown[]): number | null { let total = 0 let count = 0 for (const value of values) { const numeric = Number(value) if (!Number.isFinite(numeric)) continue total += numeric count += 1 } return count === 0 ? null : total / count } function roundValue(value: unknown): number | null { const numeric = Number(value) if (!Number.isFinite(numeric)) return null return Math.round(numeric * 10) / 10 } /** US EPA AQI category for a numeric AQI value. */ export function aqiCategory(value: unknown): string { const numeric = Number(value) if (!Number.isFinite(numeric)) return 'Unknown' if (numeric <= 50) return 'Good' if (numeric <= 100) return 'Moderate' if (numeric <= 150) return 'Unhealthy for sensitive groups' if (numeric <= 200) return 'Unhealthy' if (numeric <= 300) return 'Very unhealthy' return 'Hazardous' } function buildHourlySeries(data: any) { const hourly = data.hourly || {} const times = Array.isArray(hourly.time) ? hourly.time : [] const rows = [] for (let index = 0; index < times.length; index += 1) { rows.push({ time: hourly.time[index], usAqi: roundValue(hourly.us_aqi[index]), usCategory: aqiCategory(hourly.us_aqi[index]), europeanAqi: roundValue(hourly.european_aqi[index]), pm25: roundValue(hourly.pm2_5[index]), pm10: roundValue(hourly.pm10[index]), carbonMonoxide: roundValue(hourly.carbon_monoxide[index]), nitrogenDioxide: roundValue(hourly.nitrogen_dioxide[index]), sulphurDioxide: roundValue(hourly.sulphur_dioxide[index]), ozone: roundValue(hourly.ozone[index]), }) } return rows } /** Full Open-Meteo air-quality lookup with hourly series for a location or coordinates. */ export async function airQualityLookup(params: AirQualityLookupParams = {}) { const input = typeof params === 'object' && params ? params : {} const date = resolveDate(input.date, new Date()) const resolvedLocation = await resolveWeatherLocation(input) const airQuality = await fetchAirQualityData({ latitude: resolvedLocation.latitude, longitude: resolvedLocation.longitude, date, }) const hourlySeries = buildHourlySeries(airQuality.data) const previewLimit = Number.isFinite(Number(input.hourlyLimit)) ? Math.max(1, Number(input.hourlyLimit)) : 6 const wantedHour = input.hour === null || input.hour === undefined ? null : String(Number(input.hour)).padStart(2, '0') const hourly = wantedHour ? hourlySeries.find( (entry) => typeof entry.time === 'string' && entry.time.endsWith('T' + wantedHour + ':00'), ) || null : null const daily = { type: airQuality.isHistorical ? 'historical' : airQuality.isForecast ? 'forecast' : 'today', maxUsAqi: roundValue(maxFinite(hourlySeries.map((entry) => entry.usAqi))), maxUsCategory: aqiCategory( maxFinite(hourlySeries.map((entry) => entry.usAqi)), ), maxEuropeanAqi: roundValue( maxFinite(hourlySeries.map((entry) => entry.europeanAqi)), ), avgPm25: roundValue(averageFinite(hourlySeries.map((entry) => entry.pm25))), avgPm10: roundValue(averageFinite(hourlySeries.map((entry) => entry.pm10))), peakHour: hourlySeries.reduce<(typeof hourlySeries)[number] | null>( (best, entry) => { if (!best) return entry const bestAqi = Number(best.usAqi) const entryAqi = Number(entry.usAqi) if (!Number.isFinite(entryAqi)) return best if (!Number.isFinite(bestAqi) || entryAqi > bestAqi) return entry return best }, null, ), } const summaryLines = [ resolvedLocation.displayName + ' air quality for ' + date + ' (' + daily.type + ')', 'Timezone: ' + (airQuality.data.timezone || 'UTC'), 'Max US AQI: ' + String(daily.maxUsAqi) + ' (' + daily.maxUsCategory + ')', 'Max EU AQI: ' + String(daily.maxEuropeanAqi), 'Average PM2.5 / PM10: ' + String(daily.avgPm25) + ' / ' + String(daily.avgPm10), ] if (daily.peakHour) { summaryLines.push( 'Peak hour: ' + daily.peakHour.time + ' with US AQI ' + String(daily.peakHour.usAqi), ) } if (hourly) { summaryLines.push( 'Hour ' + wantedHour + ':00: US AQI ' + String(hourly.usAqi) + ' (' + hourly.usCategory + '), PM2.5 ' + String(hourly.pm25) + ', ozone ' + String(hourly.ozone), ) } return { location: { displayName: resolvedLocation.displayName, latitude: resolvedLocation.latitude, longitude: resolvedLocation.longitude, source: resolvedLocation.source, }, date, timeZone: airQuality.data.timezone || 'UTC', source: airQuality.source || 'open-meteo', attempts: airQuality.attempts ?? null, daily, hourly, hourlyPreview: hourlySeries.slice(0, previewLimit), summary: summaryLines.join('\n'), } } export default airQualityLookup