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