Skip to content

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

Package listing

@kody/environment

src/air-quality.ts

230 lines · 6.5 KB · TypeScript
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