Created
October 9, 2020 17:49
-
-
Save jonsadka/503b82e5aa7fd522831a03a32cd4f2a7 to your computer and use it in GitHub Desktop.
Customized AQI Widget
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use strict"; | |
/** | |
* This widget is from <https://github.com/jasonsnell/PurpleAir-AQI-Scriptable-Widget> | |
* By Jason Snell, Rob Silverii, Adam Lickel, Alexander Ogilvie, and Brian Donovan. | |
* Based on code by Matt Silverlock. | |
*/ | |
const API_URL = "https://www.purpleair.com"; | |
/** | |
* Find a nearby PurpleAir sensor ID via https://fire.airnow.gov/ | |
* Click a sensor near your location: the ID is the trailing integers | |
* https://www.purpleair.com/json has all sensors by location & ID. | |
* @type {number} | |
*/ | |
const SENSOR_ID = args.widgetParameter; | |
const DEFAULT_SENSOR_ID = 69223; | |
/** | |
* Widget attributes: AQI level threshold, text label, gradient start and end colors, text color | |
* | |
* @typedef {object} LevelAttribute | |
* @property {number} threshold | |
* @property {string} label | |
* @property {string} thresholdColor | |
*/ | |
/** | |
* @typedef {object} SensorData | |
* @property {string} val | |
* @property {string} adj1 | |
* @property {string} adj2 | |
* @property {number} ts | |
* @property {string} hum | |
* @property {string} loc | |
* @property {string} lat | |
* @property {string} lon | |
*/ | |
/** | |
* @typedef {object} LatLon | |
* @property {number} latitude | |
* @property {number} longitude | |
*/ | |
/** | |
* Get the closest PurpleAir sensorId to the given location | |
* | |
* @returns {Promise<number>} | |
*/ | |
async function getSensorId() { | |
if (SENSOR_ID) return SENSOR_ID; | |
/** @type {LatLon} */ | |
const {latitude, longitude} = await Location.current(); | |
const BOUND_OFFSET = 0.05; | |
const nwLat = latitude + BOUND_OFFSET; | |
const seLat = latitude - BOUND_OFFSET; | |
const nwLng = longitude - BOUND_OFFSET; | |
const seLng = longitude + BOUND_OFFSET; | |
const req = new Request( | |
`${API_URL}/data.json?opt=1/mAQI/a10/cC5&fetch=true&nwlat=${nwLat}&selat=${seLat}&nwlng=${nwLng}&selng=${seLng}&fields=ID` | |
); | |
/** @type {{ code?: number; data?: Array<Array<number>>; fields?: Array<string>; }} */ | |
const res = await req.loadJSON(); | |
const RATE_LIMIT = 429; | |
if (res.code === RATE_LIMIT) return DEFAULT_SENSOR_ID; | |
const {fields, data} = res; | |
const sensorIdIndex = fields.indexOf("ID"); | |
const latIndex = fields.indexOf("Lat"); | |
const lonIndex = fields.indexOf("Lon"); | |
const typeIndex = fields.indexOf("Type"); | |
const OUTDOOR = 0; | |
let closestSensor; | |
let closestDistance = Infinity; | |
for (const location of data.filter((datum) => datum[typeIndex] === OUTDOOR)) { | |
const distanceFromLocation = haversine( | |
{latitude, longitude}, | |
{latitude: location[latIndex], longitude: location[lonIndex]} | |
); | |
if (distanceFromLocation < closestDistance) { | |
closestDistance = distanceFromLocation; | |
closestSensor = location; | |
} | |
} | |
return closestSensor ? closestSensor[sensorIdIndex] : DEFAULT_SENSOR_ID; | |
} | |
/** | |
* Returns the haversine distance between start and end. | |
* | |
* @param {LatLon} start | |
* @param {LatLon} end | |
* @returns {number} | |
*/ | |
function haversine(start, end) { | |
const toRadians = (n) => (n * Math.PI) / 180; | |
const deltaLat = toRadians(end.latitude - start.latitude); | |
const deltaLon = toRadians(end.longitude - start.longitude); | |
const startLat = toRadians(start.latitude); | |
const endLat = toRadians(end.latitude); | |
const angle = | |
Math.sin(deltaLat / 2) ** 2 + | |
Math.sin(deltaLon / 2) ** 2 * Math.cos(startLat) * Math.cos(endLat); | |
return 2 * Math.atan2(Math.sqrt(angle), Math.sqrt(1 - angle)); | |
} | |
/** | |
* Fetch content from PurpleAir | |
* | |
* @param {number} sensorId | |
* @returns {Promise<SensorData>} | |
*/ | |
async function getSensorData(sensorId) { | |
const req = new Request(`${API_URL}/json?show=${sensorId}`); | |
const json = await req.loadJSON(); | |
return { | |
val: json.results[0].Stats, | |
adj1: json.results[0].pm2_5_cf_1, | |
adj2: json.results[1].pm2_5_cf_1, | |
ts: json.results[0].LastSeen, | |
hum: json.results[0].humidity, | |
loc: json.results[0].Label, | |
lat: json.results[0].Lat, | |
lon: json.results[0].Lon, | |
}; | |
} | |
/** | |
* Fetch content from PurpleAir | |
* | |
* @param {string} lat | |
* @param {string} lon | |
* @returns {Promise<GeospatialData>} | |
*/ | |
async function getGeoData(lat, lon) { | |
const providerUrl = 'https://geocode.xyz/' | |
const req = new Request(`${providerUrl}${lat},${lon}?geoit=json`); | |
const json = await req.loadJSON(); | |
return { | |
city: json.city, | |
state: json.state, | |
stateName: json.statename, | |
zip: json.postal, | |
}; | |
} | |
/** @type {Array<LevelAttribute>} sorted by threshold desc. */ | |
const LEVEL_ATTRIBUTES = [ | |
{ | |
threshold: 300, | |
label: "Hazardous", | |
thresholdColor: "#5E5CE6", | |
}, | |
{ | |
threshold: 200, | |
label: "Very Unhealthy", | |
thresholdColor: "#BF5AF2", | |
}, | |
{ | |
threshold: 150, | |
label: "Unhealthy", | |
thresholdColor: "#FF453A", | |
}, | |
{ | |
threshold: 100, | |
label: "Unhealthy for Sensitive Groups", | |
thresholdColor: "#FF9F0A", | |
}, | |
{ | |
threshold: 50, | |
label: "Moderate", | |
thresholdColor: "#FFD60A", | |
}, | |
{ | |
threshold: -20, | |
label: "Good", | |
thresholdColor: "#30D158", | |
}, | |
]; | |
/** | |
* Get the EPA adjusted PPM | |
* | |
* @param {SensorData} sensorData | |
* @returns {number} EPA draft adjustment for wood smoke and PurpleAir from https://cfpub.epa.gov/si/si_public_record_report.cfm?dirEntryId=349513&Lab=CEMM&simplesearch=0&showcriteria=2&sortby=pubDate&timstype=&datebeginpublishedpresented=08/25/2018 | |
*/ | |
function computePM(sensorData) { | |
const adj1 = Number.parseInt(sensorData.adj1, 10); | |
const adj2 = Number.parseInt(sensorData.adj2, 10); | |
const hum = Number.parseInt(sensorData.hum, 10); | |
const dataAverage = (adj1 + adj2) / 2; | |
return 0.52 * dataAverage - 0.085 * hum + 5.71; | |
} | |
/** | |
* Get AQI number from PPM reading | |
* | |
* @param {number} pm | |
* @returns {number|'-'} | |
*/ | |
function aqiFromPM(pm) { | |
if (pm > 350.5) return calculateAQI(pm, 500.0, 401.0, 500.0, 350.5); | |
if (pm > 250.5) return calculateAQI(pm, 400.0, 301.0, 350.4, 250.5); | |
if (pm > 150.5) return calculateAQI(pm, 300.0, 201.0, 250.4, 150.5); | |
if (pm > 55.5) return calculateAQI(pm, 200.0, 151.0, 150.4, 55.5); | |
if (pm > 35.5) return calculateAQI(pm, 150.0, 101.0, 55.4, 35.5); | |
if (pm > 12.1) return calculateAQI(pm, 100.0, 51.0, 35.4, 12.1); | |
if (pm >= 0.0) return calculateAQI(pm, 50.0, 0.0, 12.0, 0.0); | |
return "-"; | |
} | |
/** | |
* Calculate the AQI number | |
* | |
* @param {number} Cp | |
* @param {number} Ih | |
* @param {number} Il | |
* @param {number} BPh | |
* @param {number} BPl | |
* @returns {number} | |
*/ | |
function calculateAQI(Cp, Ih, Il, BPh, BPl) { | |
const a = Ih - Il; | |
const b = BPh - BPl; | |
const c = Cp - BPl; | |
return Math.round((a / b) * c + Il); | |
} | |
/** | |
* Calculates the AQI level | |
* based on https://cfpub.epa.gov/airnow/index.cfm?action=aqibasics.aqi#unh | |
* | |
* @param {number|'-'} aqi | |
* @returns {LevelAttribute & { level: number }} | |
*/ | |
function calculateLevel(aqi) { | |
const level = Number(aqi) || 0; | |
const { | |
label = "Weird", | |
thresholdColor = "#009900", | |
threshold = -Infinity, | |
} = LEVEL_ATTRIBUTES.find(({threshold}) => level > threshold) || {}; | |
return { | |
label, | |
thresholdColor, | |
threshold, | |
level, | |
}; | |
} | |
/** | |
* Get the AQI trend | |
* | |
* @param {{ v1: number; v3: number; }} stats | |
* @returns {string} | |
*/ | |
function getAQITrend({v1: partLive, v3: partTime}) { | |
const partDelta = partTime - partLive; | |
if (partDelta > 5) return "arrow.down"; | |
if (partDelta < -5) return "arrow.up"; | |
return "arrow.left.and.right"; | |
} | |
/** | |
* Constructs an SFSymbol from the given symbolName | |
* | |
* @param {string} symbolName | |
* @returns {object} SFSymbol | |
*/ | |
function createSymbol(symbolName) { | |
const symbol = SFSymbol.named(symbolName); | |
symbol.applyFont(Font.systemFont(20)); | |
return symbol; | |
} | |
async function run() { | |
const listWidget = new ListWidget(); | |
listWidget.setPadding(22, 16, 12, 12); | |
try { | |
const sensorId = await getSensorId(); | |
console.log(`Using sensor ID: ${SENSOR_ID}`); | |
const data = await getSensorData(sensorId); | |
const stats = JSON.parse(data.val); | |
console.log({stats}); | |
const aqiTrend = getAQITrend(stats); | |
console.log({aqiTrend}); | |
const epaPM = computePM(data); | |
console.log({epaPM}); | |
const aqi = aqiFromPM(epaPM); | |
const level = calculateLevel(aqi); | |
const aqiText = aqi.toString(); | |
console.log({aqi}); | |
listWidget.backgroundColor = new Color('#0E1012'); | |
const primaryColor = new Color('#FFFFFF'); | |
const secondaryColor = new Color('#8D8D93'); | |
const tertiaryColor = new Color('#47474A'); | |
const quaternaryColor = new Color('#2A2A2C'); | |
const header = listWidget.addText('Air Quality'.toUpperCase()); | |
header.textColor = secondaryColor; | |
header.font = Font.semiboldSystemFont(12); | |
header.minimumScaleFactor = 0.50; | |
const wordLevel = listWidget.addText(level.label); | |
wordLevel.textColor = new Color(level.thresholdColor); | |
wordLevel.font = Font.semiboldSystemFont(20); | |
wordLevel.minimumScaleFactor = 0.75; | |
listWidget.addSpacer(4); | |
const scoreStack = listWidget.addStack() | |
const content = scoreStack.addText(aqiText); | |
content.textColor = primaryColor; | |
content.font = Font.regularSystemFont(56); | |
const trendSymbol = createSymbol(aqiTrend); | |
const trendImg = scoreStack.addImage(trendSymbol.image); | |
trendImg.resizable = false; | |
trendImg.tintColor = primaryColor; | |
trendImg.imageSize = new Size(30, 38); | |
const geoData = await getGeoData(data.lat, data.lon) | |
const locationText = listWidget.addText(`${geoData.city}, ${geoData.stateName}`); | |
locationText.textColor = tertiaryColor; | |
locationText.font = Font.regularSystemFont(8); | |
const updatedAt = new Date(data.ts * 1000).toLocaleTimeString([], { | |
hour: "2-digit", | |
minute: "2-digit", | |
}); | |
const widgetText = listWidget.addText(`Updated ${updatedAt}`); | |
widgetText.textColor = tertiaryColor; | |
widgetText.font = Font.regularSystemFont(8); | |
const purpleMapUrl = `https://www.purpleair.com/map?opt=1/i/mAQI/a10/cC5&select=${SENSOR_ID}#14/${data.lat}/${data.lon}`; | |
listWidget.url = purpleMapUrl; | |
} catch (error) { | |
console.log(error); | |
const errorWidgetText = listWidget.addText(`${error}`); | |
errorWidgetText.textColor = Color.red(); | |
errorWidgetText.textOpacity = 30; | |
errorWidgetText.font = Font.regularSystemFont(10); | |
} | |
if (config.runsInApp) { | |
listWidget.presentSmall(); | |
} | |
Script.setWidget(listWidget); | |
Script.complete(); | |
} | |
await run(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment