Skip to content

Commit 726601c

Browse files
committed
feat(telemetry): NowCast EPA AQI for PM2.5 — Stage 2 (#2040)
Stage 2 of #2040 / design#54: compute a real Air Quality Index from the PM2.5 history ingested in Stage 1 and wire it into the existing (previously unused) AirQualityIndex display. - EPAAirQuality: new, pure/testable EPA math — * NowCast: 12h weighted rolling PM2.5 average (weight = max(cMin/cMax, 0.5)), requiring 2 of the 3 most recent hours of data or it returns nil. * aqi(fromPM25:): current (2024) EPA PM2.5 24-hour breakpoint table + Equation 1 (truncate to 1 decimal, linear interpolation, round), clamped to the app's 0–500 Aqi scale. * nowCastAQI(from:): buckets timestamped readings into the last 12 clock hours, runs NowCast, maps to AQI. - NodeInfoEntity.airQualityNowCastAQI: pulls the last 12h of PM2.5 telemetry and returns the NowCast AQI, or nil when there isn't enough history. - NodeDetail Air Quality section: shows the computed AQI via AirQualityIndex when available; otherwise falls back to the Stage 1 raw PM readings (design#54 — never show a misleading instantaneous AQI). - AirQualityIndex: fix the long-standing bug where it used IaqDisplayMode instead of AqiDisplayMode, and correct the hardcoded "IAQ" labels to "AQI". This view now has its first real caller. - Tests: EPAAirQualityTests — breakpoint boundaries, truncation/clamping, NowCast weighting + data-sufficiency, and end-to-end nowCastAQI from timestamped readings. - Localization: register the new AQI label strings.
1 parent d6f2178 commit 726601c

7 files changed

Lines changed: 266 additions & 16 deletions

File tree

Localizable.xcstrings

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"sourceLanguage" : "en",
33
"strings" : {
4+
"AQI" : {
5+
"comment" : "Air Quality Index label (acronym) shown on the air quality gauge."
6+
},
7+
"AQI " : {
8+
"comment" : "Air Quality Index label prefix (acronym) preceding the numeric AQI value."
9+
},
10+
"AQI %lld" : {
11+
"comment" : "Air Quality Index label with the numeric AQI value."
12+
},
413
"Air Quality" : {
514
"comment" : "Node detail section header for particulate-matter air quality readings."
615
},

Meshtastic.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@
442442
DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA1C48D28DB49D3009933EC /* ChannelRoles.swift */; };
443443
DDA3DFDA2F10B39600D8F103 /* UIKeyboardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */; };
444444
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */; };
445+
E65184282B90D941CB5626CE /* EPAAirQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */; };
445446
AABBCCDD0000000000000001 /* MeshPackets+Geofence.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */; };
446447
DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA951592BC6624100CEA535 /* TelemetryWeather.swift */; };
447448
DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */; };
@@ -1095,6 +1096,7 @@
10951096
DDA28B1B2D32C89200EF726F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = "<group>"; };
10961097
DDA3DFD92F10B39600D8F103 /* UIKeyboardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardType.swift; sourceTree = "<group>"; };
10971098
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPackets.swift; sourceTree = "<group>"; };
1099+
36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPAAirQuality.swift; sourceTree = "<group>"; };
10981100
AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MeshPackets+Geofence.swift"; sourceTree = "<group>"; };
10991101
DDA951592BC6624100CEA535 /* TelemetryWeather.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelemetryWeather.swift; sourceTree = "<group>"; };
11001102
DDA9515B2BC6631200CEA535 /* TelemetryEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryEnums.swift; sourceTree = "<group>"; };
@@ -2368,6 +2370,7 @@
23682370
DD7E5740000000000000007D /* LockdownPassphraseStore.swift */,
23692371
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
23702372
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
2373+
36D8DBD29BF3C32998C5A0A3 /* EPAAirQuality.swift */,
23712374
AABBCCDD0000000000000002 /* MeshPackets+Geofence.swift */,
23722375
84CCF00CDD3D48C9B9DCA554 /* WatchSessionManager.swift */,
23732376
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
@@ -3189,6 +3192,7 @@
31893192
23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */,
31903193
DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */,
31913194
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */,
3195+
E65184282B90D941CB5626CE /* EPAAirQuality.swift in Sources */,
31923196
AABBCCDD0000000000000001 /* MeshPackets+Geofence.swift in Sources */,
31933197
DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */,
31943198
BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */,

Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ extension NodeInfoEntity {
119119
return try? ctx.fetch(descriptor).first
120120
}
121121

122+
/// NowCast-derived AQI (0–500) computed from the last 12 hours of PM2.5 telemetry, or `nil`
123+
/// when there isn't enough recent history to compute a NowCast. Per design#54, callers should
124+
/// fall back to showing the raw PM2.5 reading rather than a misleading instantaneous AQI.
125+
var airQualityNowCastAQI: Int? {
126+
let twelveHoursAgo = Calendar.current.date(byAdding: .hour, value: -12, to: Date()) ?? .distantPast
127+
let readings = safeTelemetries(ofType: 3).compactMap { telemetry -> (date: Date, pm25: Double)? in
128+
guard let time = telemetry.time, time >= twelveHoursAgo, let pm25 = telemetry.pm25Standard else { return nil }
129+
return (date: time, pm25: Double(pm25))
130+
}
131+
return EPAAirQuality.nowCastAQI(from: readings)
132+
}
133+
122134
var hasAirQualityMetrics: Bool {
123135
guard let ctx = modelContext else { return false }
124136
let nodeNum = self.num
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//
2+
// EPAAirQuality.swift
3+
// Meshtastic
4+
//
5+
// US EPA air quality math for PM2.5 (issue #2040 / design#54):
6+
// • NowCast — a 12-hour weighted rolling average that favors recent hours.
7+
// • AQI — the 0–500 index derived from a PM2.5 concentration via EPA Equation 1.
8+
//
9+
// References:
10+
// • EPA/AirNow "Technical Assistance Document for the Reporting of Daily Air Quality"
11+
// (Equation 1, truncation + rounding rules, NowCast data-sufficiency rule).
12+
// • EPA AQS breakpoint code table (parameter 88101, PM2.5 24-hour), current
13+
// post-2024 concentration breakpoints.
14+
//
15+
16+
import Foundation
17+
18+
enum EPAAirQuality {
19+
20+
/// One row of the EPA AQI breakpoint table: a concentration range (µg/m³) mapped to an AQI range.
21+
struct PM25Breakpoint {
22+
let concentrationLow: Double
23+
let concentrationHigh: Double
24+
let aqiLow: Int
25+
let aqiHigh: Int
26+
}
27+
28+
/// Current (2024) EPA PM2.5 24-hour AQI breakpoints (µg/m³ → AQI index).
29+
/// Contiguous at 0.1 µg/m³ resolution; concentrations are truncated to one decimal
30+
/// place before lookup so the 0.1 gaps between rows (e.g. 9.0 / 9.1) never fall through.
31+
/// The app's `Aqi` scale tops out at 500, so concentrations above the final breakpoint
32+
/// clamp to 500 rather than extending into the 501–999 "beyond the AQI" range.
33+
static let pm25Breakpoints: [PM25Breakpoint] = [
34+
PM25Breakpoint(concentrationLow: 0.0, concentrationHigh: 9.0, aqiLow: 0, aqiHigh: 50),
35+
PM25Breakpoint(concentrationLow: 9.1, concentrationHigh: 35.4, aqiLow: 51, aqiHigh: 100),
36+
PM25Breakpoint(concentrationLow: 35.5, concentrationHigh: 55.4, aqiLow: 101, aqiHigh: 150),
37+
PM25Breakpoint(concentrationLow: 55.5, concentrationHigh: 125.4, aqiLow: 151, aqiHigh: 200),
38+
PM25Breakpoint(concentrationLow: 125.5, concentrationHigh: 225.4, aqiLow: 201, aqiHigh: 300),
39+
PM25Breakpoint(concentrationLow: 225.5, concentrationHigh: 325.4, aqiLow: 301, aqiHigh: 500)
40+
]
41+
42+
/// Minimum weight factor for the PM NowCast; approximates a 3-hour average when
43+
/// pollutant levels are changing rapidly.
44+
static let nowCastMinimumWeight = 0.5
45+
46+
/// AQI (0–500) from a PM2.5 concentration (µg/m³) using EPA Equation 1:
47+
/// I = (Ihi − Ilo) / (BPhi − BPlo) · (C − BPlo) + Ilo
48+
/// The concentration is truncated to one decimal place; the result is rounded to the
49+
/// nearest integer. Returns `nil` for negative input.
50+
static func aqi(fromPM25 concentration: Double) -> Int? {
51+
guard concentration >= 0 else { return nil }
52+
// Truncate to one decimal place (EPA rule for PM2.5). Direct sensor readings are integer
53+
// µg/m³ (Stage 1 stores pm25Standard as UInt32) so they truncate exactly; NowCast averages
54+
// are fractional, where an exact-boundary hit is measure-zero and at most ±1 AQI. `.towardZero`
55+
// == floor here because the value is already guarded non-negative.
56+
let c = (concentration * 10).rounded(.towardZero) / 10.0
57+
for bp in pm25Breakpoints where c <= bp.concentrationHigh {
58+
let slope = Double(bp.aqiHigh - bp.aqiLow) / (bp.concentrationHigh - bp.concentrationLow)
59+
let index = slope * (c - bp.concentrationLow) + Double(bp.aqiLow)
60+
return min(500, max(0, Int(index.rounded())))
61+
}
62+
// Above the top breakpoint: clamp to the ceiling of the app's AQI scale.
63+
return 500
64+
}
65+
66+
/// EPA NowCast for PM2.5 from up to 12 hourly averages.
67+
/// `hourly[0]` is the most recent clock hour, `hourly[11]` is 11 hours earlier; `nil`
68+
/// marks a missing hour. Returns `nil` when there isn't enough recent data — EPA requires
69+
/// at least two of the three most recent hours to be valid.
70+
static func nowCastPM25(hourly: [Double?]) -> Double? {
71+
let hours = Array(hourly.prefix(12))
72+
// Data sufficiency: 2 of the 3 most recent hours must be present.
73+
guard hours.prefix(3).compactMap({ $0 }).count >= 2 else { return nil }
74+
75+
let valid = hours.compactMap { $0 }
76+
guard let cMax = valid.max(), let cMin = valid.min() else { return nil }
77+
// If the max is 0 every reading is 0 → NowCast is 0 (avoids divide-by-zero on the weight).
78+
guard cMax > 0 else { return 0 }
79+
80+
// Weight factor = 1 − (scaled rate of change), floored at the minimum weight.
81+
let weightFactor = max(cMin / cMax, nowCastMinimumWeight)
82+
83+
var numerator = 0.0
84+
var denominator = 0.0
85+
for (hoursAgo, value) in hours.enumerated() {
86+
guard let concentration = value else { continue }
87+
let weight = pow(weightFactor, Double(hoursAgo))
88+
numerator += weight * concentration
89+
denominator += weight
90+
}
91+
guard denominator > 0 else { return nil }
92+
return numerator / denominator
93+
}
94+
95+
/// Buckets timestamped PM2.5 readings into the most recent 12 clock hours, computes the
96+
/// NowCast, and maps it to an AQI (0–500). Returns `nil` when NowCast can't be computed
97+
/// (insufficient recent history) — callers should fall back to showing the raw reading.
98+
static func nowCastAQI(
99+
from readings: [(date: Date, pm25: Double)],
100+
now: Date = Date(),
101+
calendar: Calendar = .current
102+
) -> Int? {
103+
guard !readings.isEmpty else { return nil }
104+
let currentHourStart = calendar.dateInterval(of: .hour, for: now)?.start ?? now
105+
106+
var buckets: [Int: (sum: Double, count: Int)] = [:]
107+
for reading in readings {
108+
guard let hourStart = calendar.dateInterval(of: .hour, for: reading.date)?.start else { continue }
109+
guard let hoursAgo = calendar.dateComponents([.hour], from: hourStart, to: currentHourStart).hour else { continue }
110+
guard hoursAgo >= 0, hoursAgo < 12 else { continue }
111+
var bucket = buckets[hoursAgo] ?? (sum: 0, count: 0)
112+
bucket.sum += reading.pm25
113+
bucket.count += 1
114+
buckets[hoursAgo] = bucket
115+
}
116+
117+
let hourly: [Double?] = (0..<12).map { hoursAgo in
118+
guard let bucket = buckets[hoursAgo], bucket.count > 0 else { return nil }
119+
return bucket.sum / Double(bucket.count)
120+
}
121+
122+
guard let nowCast = nowCastPM25(hourly: hourly) else { return nil }
123+
return aqi(fromPM25: nowCast)
124+
}
125+
}

Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ enum AqiDisplayMode: Int, CaseIterable, Identifiable {
1919

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

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

52-
Text("IAQ")
52+
Text("AQI")
5353
.foregroundColor(aqiEnum.color)
5454
} currentValueLabel: {
5555
Text("\(Int(aqi))")
@@ -59,10 +59,10 @@ struct AirQualityIndex: View {
5959
case .gradient:
6060
HStack {
6161
Gauge(value: Double(aqi), in: 0...500) {
62-
Text("IAQ")
62+
Text("AQI")
6363
.foregroundColor(aqiEnum.color)
6464
} currentValueLabel: {
65-
Text("IAQ ")+Text("\(Int(aqi))")
65+
Text("AQI ")+Text("\(Int(aqi))")
6666
.foregroundColor(.gray)
6767
}
6868
.tint(gradient)

Meshtastic/Views/Nodes/Helpers/NodeDetail.swift

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ struct NodeDetail: View {
4646
@State private var latestDeviceMetrics: TelemetryEntity?
4747
@State private var latestEnvironmentMetrics: TelemetryEntity?
4848
@State private var latestAirQualityMetrics: TelemetryEntity?
49+
@State private var airQualityNowCastAQI: Int?
4950
@State private var latestPowerMetrics: TelemetryEntity?
5051
@State private var logAvailability = NodeDetailLogAvailability()
5152

@@ -496,20 +497,27 @@ struct NodeDetail: View {
496497
if let metrics = latestAirQualityMetrics,
497498
metrics.pm25Standard != nil || metrics.pm10Standard != nil || metrics.pm100Standard != nil {
498499
Section("Air Quality") {
499-
// design#54: with insufficient history to compute a NowCast AQI, show the raw
500-
// particulate-matter readings (µg/m³) rather than a misleading instantaneous AQI.
501-
LazyVGrid(columns: gridItemLayout) {
502-
if let pm25 = metrics.pm25Standard {
503-
ParticulateMatterCompactWidget(label: "PM2.5", value: pm25)
504-
}
505-
if let pm10 = metrics.pm10Standard {
506-
ParticulateMatterCompactWidget(label: "PM1.0", value: pm10)
500+
VStack {
501+
// design#54: once ~12h of PM2.5 history exists, headline the NowCast-derived EPA AQI
502+
// gauge above the raw particulate-matter readings (µg/m³). Without enough history the
503+
// gauge is omitted and only the raw readings show — never a misleading instantaneous AQI.
504+
if let aqi = airQualityNowCastAQI {
505+
AirQualityIndex(aqi: aqi, displayMode: .gradient)
506+
.padding(.vertical)
507507
}
508-
if let pm100 = metrics.pm100Standard {
509-
ParticulateMatterCompactWidget(label: "PM10", value: pm100)
508+
LazyVGrid(columns: gridItemLayout) {
509+
if let pm25 = metrics.pm25Standard {
510+
ParticulateMatterCompactWidget(label: "PM2.5", value: pm25)
511+
}
512+
if let pm10 = metrics.pm10Standard {
513+
ParticulateMatterCompactWidget(label: "PM1.0", value: pm10)
514+
}
515+
if let pm100 = metrics.pm100Standard {
516+
ParticulateMatterCompactWidget(label: "PM10", value: pm100)
517+
}
510518
}
519+
.padding(.bottom)
511520
}
512-
.padding(.vertical)
513521
}
514522
.accessibilityElement(children: .combine)
515523
}
@@ -836,11 +844,13 @@ struct NodeDetail: View {
836844
let deviceMetrics = node.latestDeviceMetrics
837845
let environmentMetrics = node.latestEnvironmentMetrics
838846
let airQualityMetrics = node.latestAirQualityMetrics
847+
let airQualityAQI = node.airQualityNowCastAQI
839848
let powerMetrics = node.latestPowerMetrics
840849
let position = node.latestPosition
841850
latestDeviceMetrics = deviceMetrics
842851
latestEnvironmentMetrics = environmentMetrics
843852
latestAirQualityMetrics = airQualityMetrics
853+
airQualityNowCastAQI = airQualityAQI
844854
latestPowerMetrics = powerMetrics
845855
latestPosition = position
846856
logAvailability = NodeDetailLogAvailability(

MeshtasticTests/MeshPacketsAndTelemetryTests.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,96 @@ struct LocalStatsTelemetryExportTests {
8585
}
8686
}
8787

88+
// EPA PM2.5 → AQI breakpoints and NowCast — issue #2040 / design#54 (Stage 2)
89+
@Suite("EPA air quality math")
90+
struct EPAAirQualityTests {
91+
92+
// MARK: AQI from PM2.5 concentration (current 2024 EPA breakpoints)
93+
94+
@Test func aqiBreakpointBoundaries() {
95+
#expect(EPAAirQuality.aqi(fromPM25: 0.0) == 0)
96+
#expect(EPAAirQuality.aqi(fromPM25: 9.0) == 50) // top of Good
97+
#expect(EPAAirQuality.aqi(fromPM25: 9.1) == 51) // bottom of Moderate
98+
#expect(EPAAirQuality.aqi(fromPM25: 35.4) == 100) // top of Moderate
99+
#expect(EPAAirQuality.aqi(fromPM25: 35.5) == 101) // bottom of USG
100+
#expect(EPAAirQuality.aqi(fromPM25: 55.4) == 150)
101+
#expect(EPAAirQuality.aqi(fromPM25: 125.4) == 200)
102+
#expect(EPAAirQuality.aqi(fromPM25: 225.4) == 300)
103+
#expect(EPAAirQuality.aqi(fromPM25: 225.5) == 301) // bottom of Hazardous
104+
#expect(EPAAirQuality.aqi(fromPM25: 325.4) == 500) // top of the app's AQI scale
105+
}
106+
107+
@Test func aqiClampsAndTruncates() {
108+
#expect(EPAAirQuality.aqi(fromPM25: 400.0) == 500) // above the table → clamp to 500
109+
#expect(EPAAirQuality.aqi(fromPM25: 9.05) == 50) // truncates to 9.0, not rounded up
110+
#expect(EPAAirQuality.aqi(fromPM25: -1.0) == nil) // invalid
111+
}
112+
113+
// MARK: NowCast
114+
115+
@Test func nowCastConstantSeriesEqualsValue() {
116+
let hourly: [Double?] = Array(repeating: 10.0, count: 12)
117+
#expect(EPAAirQuality.nowCastPM25(hourly: hourly) == 10.0)
118+
}
119+
120+
@Test func nowCastRequiresTwoOfThreeRecentHours() {
121+
// Only the most recent hour present → insufficient.
122+
let insufficient: [Double?] = [10.0, nil, nil, 10.0, 10.0, 10.0]
123+
#expect(EPAAirQuality.nowCastPM25(hourly: insufficient) == nil)
124+
// Two of the three most recent hours present → computes.
125+
let sufficient: [Double?] = [10.0, nil, 10.0]
126+
#expect(EPAAirQuality.nowCastPM25(hourly: sufficient) == 10.0)
127+
}
128+
129+
@Test func nowCastWeightsRecentHoursMoreHeavily() {
130+
// cMin/cMax = 1/100 < 0.5, so the weight factor floors at 0.5.
131+
// NowCast = (0.5^0·1 + 0.5^1·100) / (1 + 0.5) = 51 / 1.5 = 34.
132+
let hourly: [Double?] = [1.0, 100.0]
133+
let nowCast = EPAAirQuality.nowCastPM25(hourly: hourly)
134+
#expect(nowCast != nil)
135+
#expect(abs((nowCast ?? 0) - 34.0) < 0.0001)
136+
}
137+
138+
@Test func nowCastAQIFromTimestampedReadings() {
139+
let calendar = Calendar.current
140+
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
141+
let readings: [(date: Date, pm25: Double)] = [
142+
(now, 10.0),
143+
(calendar.date(byAdding: .hour, value: -1, to: now) ?? now, 10.0),
144+
(calendar.date(byAdding: .hour, value: -2, to: now) ?? now, 10.0)
145+
]
146+
// NowCast of a constant 10 µg/m³ → 10.0 → AQI 53 (Moderate).
147+
#expect(EPAAirQuality.nowCastAQI(from: readings, now: now, calendar: calendar) == 53)
148+
}
149+
150+
@Test func nowCastAQIReturnsNilWithoutEnoughHistory() {
151+
let calendar = Calendar.current
152+
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
153+
// A single reading is not enough (needs 2 of the 3 most recent hours).
154+
#expect(EPAAirQuality.nowCastAQI(from: [(now, 42.0)], now: now, calendar: calendar) == nil)
155+
}
156+
157+
@Test func nowCastAllZeroReadingsIsZero() {
158+
// All-zero series: max is 0, so the weight can't be derived — NowCast short-circuits to 0.
159+
let hourly: [Double?] = [0.0, 0.0, 0.0]
160+
#expect(EPAAirQuality.nowCastPM25(hourly: hourly) == 0.0)
161+
#expect(EPAAirQuality.aqi(fromPM25: 0.0) == 0)
162+
}
163+
164+
@Test func nowCastAQIExcludesReadingsOutsideTwelveHourWindow() {
165+
let calendar = Calendar.current
166+
let now = calendar.date(from: DateComponents(year: 2025, month: 6, day: 1, hour: 12, minute: 30)) ?? Date()
167+
// Two recent hours plus one stale reading 20h ago that must be ignored.
168+
let readings: [(date: Date, pm25: Double)] = [
169+
(now, 10.0),
170+
(calendar.date(byAdding: .hour, value: -1, to: now) ?? now, 10.0),
171+
(calendar.date(byAdding: .hour, value: -20, to: now) ?? now, 999.0)
172+
]
173+
// The 999 reading is outside the 12h window; NowCast of the constant 10 → AQI 53.
174+
#expect(EPAAirQuality.nowCastAQI(from: readings, now: now, calendar: calendar) == 53)
175+
}
176+
}
177+
88178
// Air Quality (particulate matter) telemetry — issue #2040
89179
@Suite("Air quality telemetry export")
90180
struct AirQualityTelemetryExportTests {

0 commit comments

Comments
 (0)