diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 503704c2c..c96646b51 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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." }, diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 95d6bd736..4263f7f19 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1105,6 +1106,7 @@ DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = ""; }; DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardType.swift; sourceTree = ""; }; DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPackets.swift; sourceTree = ""; }; + 36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPAAirQuality.swift; sourceTree = ""; }; AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MeshPackets+Geofence.swift"; sourceTree = ""; }; DDA951592BC6624100CEA535 /* TelemetryWeather.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelemetryWeather.swift; sourceTree = ""; }; DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryEnums.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift index 89364b5cd..79d2d9fc8 100644 --- a/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift @@ -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 diff --git a/Meshtastic/Helpers/EPAAirQuality.swift b/Meshtastic/Helpers/EPAAirQuality.swift new file mode 100644 index 000000000..5d6756978 --- /dev/null +++ b/Meshtastic/Helpers/EPAAirQuality.swift @@ -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) + } +} diff --git a/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift b/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift index 66307a913..5d5b4a181 100644 --- a/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift +++ b/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift @@ -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 { @@ -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: @@ -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))") @@ -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) diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index e4dbdb902..9ba1702a6 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -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() @@ -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) } @@ -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( diff --git a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift index e95681d64..2ab3b79b4 100644 --- a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift +++ b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift @@ -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 {