Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
{
"sourceLanguage" : "en",
"strings" : {
"AQI" : {
"comment" : "Air Quality Index label (acronym) shown on the air quality gauge."
},
"AQI " : {
"comment" : "Air Quality Index label prefix (acronym) preceding the numeric AQI value."
},
"AQI %lld" : {
"comment" : "Air Quality Index label with the numeric AQI value."
},
"Air Quality" : {
"comment" : "Node detail section header for particulate-matter air quality readings."
},
Expand Down
4 changes: 4 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@
DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA1C48D28DB49D3009933EC /* ChannelRoles.swift */; };
DDA3DFDA2F10B39600D8F103 /* UIKeyboardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */; };
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */; };
E65184282B90D941CB5626CE /* EPAAirQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */; };
AABBCCDD0000000000000001 /* MeshPackets+Geofence.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */; };
DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA951592BC6624100CEA535 /* TelemetryWeather.swift */; };
DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */; };
Expand Down Expand Up @@ -1105,6 +1106,7 @@
DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = "<group>"; };
DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardType.swift; sourceTree = "<group>"; };
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPackets.swift; sourceTree = "<group>"; };
36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPAAirQuality.swift; sourceTree = "<group>"; };
AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MeshPackets+Geofence.swift"; sourceTree = "<group>"; };
DDA951592BC6624100CEA535 /* TelemetryWeather.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelemetryWeather.swift; sourceTree = "<group>"; };
DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryEnums.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2383,6 +2385,7 @@
DD7E5740000000000000007D /* LockdownPassphraseStore.swift */,
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */,
AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */,
84CCF00CDD3D48C9B9DCA554 /* WatchSessionManager.swift */,
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
Expand Down Expand Up @@ -3209,6 +3212,7 @@
23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */,
DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */,
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */,
E65184282B90D941CB5626CE /* EPAAirQuality.swift in Sources */,
AABBCCDD0000000000000001 /* MeshPackets+Geofence.swift in Sources */,
DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */,
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */,
Expand Down
12 changes: 12 additions & 0 deletions Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ extension NodeInfoEntity {
return try? ctx.fetch(descriptor).first
}

/// NowCast-derived AQI (0–500) computed from the last 12 hours of PM2.5 telemetry, or `nil`
/// when there isn't enough recent history to compute a NowCast. Per design#54, callers should
/// fall back to showing the raw PM2.5 reading rather than a misleading instantaneous AQI.
var airQualityNowCastAQI: Int? {
let twelveHoursAgo = Calendar.current.date(byAdding: .hour, value: -12, to: Date()) ?? .distantPast
let readings = safeTelemetries(ofType: 3).compactMap { telemetry -> (date: Date, pm25: Double)? in
guard let time = telemetry.time, time >= twelveHoursAgo, let pm25 = telemetry.pm25Standard else { return nil }
return (date: time, pm25: Double(pm25))
}
return EPAAirQuality.nowCastAQI(from: readings)
}

var hasAirQualityMetrics: Bool {
guard let ctx = modelContext else { return false }
let nodeNum = self.num
Expand Down
125 changes: 125 additions & 0 deletions Meshtastic/Helpers/EPAAirQuality.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//
// EPAAirQuality.swift
// Meshtastic
//
// US EPA air quality math for PM2.5 (issue #2040 / design#54):
// • NowCast — a 12-hour weighted rolling average that favors recent hours.
// • AQI — the 0–500 index derived from a PM2.5 concentration via EPA Equation 1.
//
// References:
// • EPA/AirNow "Technical Assistance Document for the Reporting of Daily Air Quality"
// (Equation 1, truncation + rounding rules, NowCast data-sufficiency rule).
// • EPA AQS breakpoint code table (parameter 88101, PM2.5 24-hour), current
// post-2024 concentration breakpoints.
//

import Foundation

enum EPAAirQuality {

/// One row of the EPA AQI breakpoint table: a concentration range (µg/m³) mapped to an AQI range.
struct PM25Breakpoint {
let concentrationLow: Double
let concentrationHigh: Double
let aqiLow: Int
let aqiHigh: Int
}

/// Current (2024) EPA PM2.5 24-hour AQI breakpoints (µg/m³ → AQI index).
/// Contiguous at 0.1 µg/m³ resolution; concentrations are truncated to one decimal
/// place before lookup so the 0.1 gaps between rows (e.g. 9.0 / 9.1) never fall through.
/// The app's `Aqi` scale tops out at 500, so concentrations above the final breakpoint
/// clamp to 500 rather than extending into the 501–999 "beyond the AQI" range.
static let pm25Breakpoints: [PM25Breakpoint] = [
PM25Breakpoint(concentrationLow: 0.0, concentrationHigh: 9.0, aqiLow: 0, aqiHigh: 50),
PM25Breakpoint(concentrationLow: 9.1, concentrationHigh: 35.4, aqiLow: 51, aqiHigh: 100),
PM25Breakpoint(concentrationLow: 35.5, concentrationHigh: 55.4, aqiLow: 101, aqiHigh: 150),
PM25Breakpoint(concentrationLow: 55.5, concentrationHigh: 125.4, aqiLow: 151, aqiHigh: 200),
PM25Breakpoint(concentrationLow: 125.5, concentrationHigh: 225.4, aqiLow: 201, aqiHigh: 300),
PM25Breakpoint(concentrationLow: 225.5, concentrationHigh: 325.4, aqiLow: 301, aqiHigh: 500)
]

/// Minimum weight factor for the PM NowCast; approximates a 3-hour average when
/// pollutant levels are changing rapidly.
static let nowCastMinimumWeight = 0.5

/// AQI (0–500) from a PM2.5 concentration (µg/m³) using EPA Equation 1:
/// I = (Ihi − Ilo) / (BPhi − BPlo) · (C − BPlo) + Ilo
/// The concentration is truncated to one decimal place; the result is rounded to the
/// nearest integer. Returns `nil` for negative input.
static func aqi(fromPM25 concentration: Double) -> Int? {
guard concentration >= 0 else { return nil }
// Truncate to one decimal place (EPA rule for PM2.5). Direct sensor readings are integer
// µg/m³ (Stage 1 stores pm25Standard as UInt32) so they truncate exactly; NowCast averages
// are fractional, where an exact-boundary hit is measure-zero and at most ±1 AQI. `.towardZero`
// == floor here because the value is already guarded non-negative.
let c = (concentration * 10).rounded(.towardZero) / 10.0
for bp in pm25Breakpoints where c <= bp.concentrationHigh {
let slope = Double(bp.aqiHigh - bp.aqiLow) / (bp.concentrationHigh - bp.concentrationLow)
let index = slope * (c - bp.concentrationLow) + Double(bp.aqiLow)
return min(500, max(0, Int(index.rounded())))
}
// Above the top breakpoint: clamp to the ceiling of the app's AQI scale.
return 500
}

/// EPA NowCast for PM2.5 from up to 12 hourly averages.
/// `hourly[0]` is the most recent clock hour, `hourly[11]` is 11 hours earlier; `nil`
/// marks a missing hour. Returns `nil` when there isn't enough recent data — EPA requires
/// at least two of the three most recent hours to be valid.
static func nowCastPM25(hourly: [Double?]) -> Double? {
let hours = Array(hourly.prefix(12))
// Data sufficiency: 2 of the 3 most recent hours must be present.
guard hours.prefix(3).compactMap({ $0 }).count >= 2 else { return nil }

let valid = hours.compactMap { $0 }
guard let cMax = valid.max(), let cMin = valid.min() else { return nil }
// If the max is 0 every reading is 0 → NowCast is 0 (avoids divide-by-zero on the weight).
guard cMax > 0 else { return 0 }

// Weight factor = 1 − (scaled rate of change), floored at the minimum weight.
let weightFactor = max(cMin / cMax, nowCastMinimumWeight)

var numerator = 0.0
var denominator = 0.0
for (hoursAgo, value) in hours.enumerated() {
guard let concentration = value else { continue }
let weight = pow(weightFactor, Double(hoursAgo))
numerator += weight * concentration
denominator += weight
}
guard denominator > 0 else { return nil }
return numerator / denominator
}

/// Buckets timestamped PM2.5 readings into the most recent 12 clock hours, computes the
/// NowCast, and maps it to an AQI (0–500). Returns `nil` when NowCast can't be computed
/// (insufficient recent history) — callers should fall back to showing the raw reading.
static func nowCastAQI(
from readings: [(date: Date, pm25: Double)],
now: Date = Date(),
calendar: Calendar = .current
) -> Int? {
guard !readings.isEmpty else { return nil }
let currentHourStart = calendar.dateInterval(of: .hour, for: now)?.start ?? now

var buckets: [Int: (sum: Double, count: Int)] = [:]
for reading in readings {
guard let hourStart = calendar.dateInterval(of: .hour, for: reading.date)?.start else { continue }
guard let hoursAgo = calendar.dateComponents([.hour], from: hourStart, to: currentHourStart).hour else { continue }
guard hoursAgo >= 0, hoursAgo < 12 else { continue }
var bucket = buckets[hoursAgo] ?? (sum: 0, count: 0)
bucket.sum += reading.pm25
bucket.count += 1
buckets[hoursAgo] = bucket
}

let hourly: [Double?] = (0..<12).map { hoursAgo in
guard let bucket = buckets[hoursAgo], bucket.count > 0 else { return nil }
return bucket.sum / Double(bucket.count)
}

guard let nowCast = nowCastPM25(hourly: hourly) else { return nil }
return aqi(fromPM25: nowCast)
}
}
10 changes: 5 additions & 5 deletions Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ enum AqiDisplayMode: Int, CaseIterable, Identifiable {

struct AirQualityIndex: View {
var aqi: Int
var displayMode: IaqDisplayMode = .pill
var displayMode: AqiDisplayMode = .pill
let gradient = Gradient(colors: [.green, .yellow, .orange, .red, .purple, .magenta])

var body: some View {
Expand All @@ -31,7 +31,7 @@ struct AirQualityIndex: View {
RoundedRectangle(cornerRadius: 10)
.fill(aqiEnum.color)
.frame(width: 125, height: 30)
Label("IAQ \(aqi)", systemImage: aqi < 100 ? "aqi.low" : ((aqi > 100 && aqi < 201) ? "aqi.medium" : "aqi.high"))
Label("AQI \(aqi)", systemImage: aqi < 100 ? "aqi.low" : ((aqi > 100 && aqi < 201) ? "aqi.medium" : "aqi.high"))
.padding(.leading, 4)
}
case .dot:
Expand All @@ -49,7 +49,7 @@ struct AirQualityIndex: View {
case .gauge:
Gauge(value: Double(aqi), in: 0...500) {

Text("IAQ")
Text("AQI")
.foregroundColor(aqiEnum.color)
} currentValueLabel: {
Text("\(Int(aqi))")
Expand All @@ -59,10 +59,10 @@ struct AirQualityIndex: View {
case .gradient:
HStack {
Gauge(value: Double(aqi), in: 0...500) {
Text("IAQ")
Text("AQI")
.foregroundColor(aqiEnum.color)
} currentValueLabel: {
Text("IAQ ")+Text("\(Int(aqi))")
Text("AQI ")+Text("\(Int(aqi))")
.foregroundColor(.gray)
}
.tint(gradient)
Expand Down
32 changes: 21 additions & 11 deletions Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ struct NodeDetail: View {
@State private var latestDeviceMetrics: TelemetryEntity?
@State private var latestEnvironmentMetrics: TelemetryEntity?
@State private var latestAirQualityMetrics: TelemetryEntity?
@State private var airQualityNowCastAQI: Int?
@State private var latestPowerMetrics: TelemetryEntity?
@State private var logAvailability = NodeDetailLogAvailability()

Expand Down Expand Up @@ -496,20 +497,27 @@ struct NodeDetail: View {
if let metrics = latestAirQualityMetrics,
metrics.pm25Standard != nil || metrics.pm10Standard != nil || metrics.pm100Standard != nil {
Section("Air Quality") {
// design#54: with insufficient history to compute a NowCast AQI, show the raw
// particulate-matter readings (µg/m³) rather than a misleading instantaneous AQI.
LazyVGrid(columns: gridItemLayout) {
if let pm25 = metrics.pm25Standard {
ParticulateMatterCompactWidget(label: "PM2.5", value: pm25)
}
if let pm10 = metrics.pm10Standard {
ParticulateMatterCompactWidget(label: "PM1.0", value: pm10)
VStack {
// design#54: once ~12h of PM2.5 history exists, headline the NowCast-derived EPA AQI
// gauge above the raw particulate-matter readings (µg/m³). Without enough history the
// gauge is omitted and only the raw readings show — never a misleading instantaneous AQI.
if let aqi = airQualityNowCastAQI {
AirQualityIndex(aqi: aqi, displayMode: .gradient)
.padding(.vertical)
}
if let pm100 = metrics.pm100Standard {
ParticulateMatterCompactWidget(label: "PM10", value: pm100)
LazyVGrid(columns: gridItemLayout) {
if let pm25 = metrics.pm25Standard {
ParticulateMatterCompactWidget(label: "PM2.5", value: pm25)
}
if let pm10 = metrics.pm10Standard {
ParticulateMatterCompactWidget(label: "PM1.0", value: pm10)
}
if let pm100 = metrics.pm100Standard {
ParticulateMatterCompactWidget(label: "PM10", value: pm100)
}
}
.padding(.bottom)
}
.padding(.vertical)
}
.accessibilityElement(children: .combine)
}
Expand Down Expand Up @@ -836,11 +844,13 @@ struct NodeDetail: View {
let deviceMetrics = node.latestDeviceMetrics
let environmentMetrics = node.latestEnvironmentMetrics
let airQualityMetrics = node.latestAirQualityMetrics
let airQualityAQI = node.airQualityNowCastAQI
let powerMetrics = node.latestPowerMetrics
let position = node.latestPosition
latestDeviceMetrics = deviceMetrics
latestEnvironmentMetrics = environmentMetrics
latestAirQualityMetrics = airQualityMetrics
airQualityNowCastAQI = airQualityAQI
latestPowerMetrics = powerMetrics
latestPosition = position
logAvailability = NodeDetailLogAvailability(
Expand Down
90 changes: 90 additions & 0 deletions MeshtasticTests/MeshPacketsAndTelemetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,96 @@ struct LocalStatsTelemetryExportTests {
}
}

// EPA PM2.5 → AQI breakpoints and NowCast — issue #2040 / design#54 (Stage 2)
@Suite("EPA air quality math")
struct EPAAirQualityTests {

// MARK: AQI from PM2.5 concentration (current 2024 EPA breakpoints)

@Test func aqiBreakpointBoundaries() {
#expect(EPAAirQuality.aqi(fromPM25: 0.0) == 0)
#expect(EPAAirQuality.aqi(fromPM25: 9.0) == 50) // top of Good
#expect(EPAAirQuality.aqi(fromPM25: 9.1) == 51) // bottom of Moderate
#expect(EPAAirQuality.aqi(fromPM25: 35.4) == 100) // top of Moderate
#expect(EPAAirQuality.aqi(fromPM25: 35.5) == 101) // bottom of USG
#expect(EPAAirQuality.aqi(fromPM25: 55.4) == 150)
#expect(EPAAirQuality.aqi(fromPM25: 125.4) == 200)
#expect(EPAAirQuality.aqi(fromPM25: 225.4) == 300)
#expect(EPAAirQuality.aqi(fromPM25: 225.5) == 301) // bottom of Hazardous
#expect(EPAAirQuality.aqi(fromPM25: 325.4) == 500) // top of the app's AQI scale
}

@Test func aqiClampsAndTruncates() {
#expect(EPAAirQuality.aqi(fromPM25: 400.0) == 500) // above the table → clamp to 500
#expect(EPAAirQuality.aqi(fromPM25: 9.05) == 50) // truncates to 9.0, not rounded up
#expect(EPAAirQuality.aqi(fromPM25: -1.0) == nil) // invalid
}

// MARK: NowCast

@Test func nowCastConstantSeriesEqualsValue() {
let hourly: [Double?] = Array(repeating: 10.0, count: 12)
#expect(EPAAirQuality.nowCastPM25(hourly: hourly) == 10.0)
}

@Test func nowCastRequiresTwoOfThreeRecentHours() {
// Only the most recent hour present → insufficient.
let insufficient: [Double?] = [10.0, nil, nil, 10.0, 10.0, 10.0]
#expect(EPAAirQuality.nowCastPM25(hourly: insufficient) == nil)
// Two of the three most recent hours present → computes.
let sufficient: [Double?] = [10.0, nil, 10.0]
#expect(EPAAirQuality.nowCastPM25(hourly: sufficient) == 10.0)
}

@Test func nowCastWeightsRecentHoursMoreHeavily() {
// cMin/cMax = 1/100 < 0.5, so the weight factor floors at 0.5.
// NowCast = (0.5^0·1 + 0.5^1·100) / (1 + 0.5) = 51 / 1.5 = 34.
let hourly: [Double?] = [1.0, 100.0]
let nowCast = EPAAirQuality.nowCastPM25(hourly: hourly)
#expect(nowCast != nil)
#expect(abs((nowCast ?? 0) - 34.0) < 0.0001)
}

@Test func nowCastAQIFromTimestampedReadings() {
let calendar = Calendar.current
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
let readings: [(date: Date, pm25: Double)] = [
(now, 10.0),
(calendar.date(byAdding: .hour, value: -1, to: now) ?? now, 10.0),
(calendar.date(byAdding: .hour, value: -2, to: now) ?? now, 10.0)
]
// NowCast of a constant 10 µg/m³ → 10.0 → AQI 53 (Moderate).
#expect(EPAAirQuality.nowCastAQI(from: readings, now: now, calendar: calendar) == 53)
}

@Test func nowCastAQIReturnsNilWithoutEnoughHistory() {
let calendar = Calendar.current
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
// A single reading is not enough (needs 2 of the 3 most recent hours).
#expect(EPAAirQuality.nowCastAQI(from: [(now, 42.0)], now: now, calendar: calendar) == nil)
}

@Test func nowCastAllZeroReadingsIsZero() {
// All-zero series: max is 0, so the weight can't be derived — NowCast short-circuits to 0.
let hourly: [Double?] = [0.0, 0.0, 0.0]
#expect(EPAAirQuality.nowCastPM25(hourly: hourly) == 0.0)
#expect(EPAAirQuality.aqi(fromPM25: 0.0) == 0)
}

@Test func nowCastAQIExcludesReadingsOutsideTwelveHourWindow() {
let calendar = Calendar.current
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
// Two recent hours plus one stale reading 20h ago that must be ignored.
let readings: [(date: Date, pm25: Double)] = [
(now, 10.0),
(calendar.date(byAdding: .hour, value: -1, to: now) ?? now, 10.0),
(calendar.date(byAdding: .hour, value: -20, to: now) ?? now, 999.0)
]
// The 999 reading is outside the 12h window; NowCast of the constant 10 → AQI 53.
#expect(EPAAirQuality.nowCastAQI(from: readings, now: now, calendar: calendar) == 53)
}
}

// Air Quality (particulate matter) telemetry — issue #2040
@Suite("Air quality telemetry export")
struct AirQualityTelemetryExportTests {
Expand Down
Loading