// // WeatherDataSource.swift // iris // // Created by Codex. // import CoreLocation import Foundation import WeatherKit struct WeatherAlertConfig: Sendable { var rainLookaheadSec: Int = 20 * 60 var precipitationChanceThreshold: Double = 0.5 var gustThresholdMps: Double? = nil var rainTTL: Int = 1800 var windTTL: Int = 3600 init() {} } struct WeatherWarning: Codable, Equatable, Sendable { let id: String let title: String let subtitle: String let ttlSec: Int let confidence: Double enum CodingKeys: String, CodingKey { case id case title case subtitle case ttlSec = "ttl_sec" case confidence } } @available(iOS 16.0, *) final class WeatherDataSource { struct WeatherData: Sendable, Equatable { struct Current: Sendable, Equatable { let temperatureC: Int let feelsLikeC: Int let condition: WeatherKit.WeatherCondition } enum RainSource: String, Sendable, Equatable { case minutely case hourlyApprox } struct RainSoon: Sendable, Equatable { let startAt: Int let ttlSec: Int let source: RainSource } struct WindAlert: Sendable, Equatable { let gustMps: Double let thresholdMps: Double let ttlSec: Int } let current: Current? let rainSoon: RainSoon? let windAlert: WindAlert? let warnings: [WeatherWarning] } private let service: WeatherService private let config: WeatherAlertConfig init(service: WeatherService = .shared, config: WeatherAlertConfig = .init()) { self.service = service self.config = config } struct Snapshot: Sendable { let data: WeatherData let diagnostics: [String: String] } enum WeatherError: Error, LocalizedError, Sendable { case weatherKitFailed(message: String, diagnostics: [String: String]) var errorDescription: String? { switch self { case .weatherKitFailed(let message, _): return message } } } func dataWithDiagnostics(for location: CLLocation, now: Int) async throws -> Snapshot { var current: WeatherData.Current? = nil var rainSoon: WeatherData.RainSoon? = nil var windAlert: WeatherData.WindAlert? = nil var diagnostics: [String: String] = [ "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), "now": String(now), "rain_lookahead_sec": String(config.rainLookaheadSec), "precip_chance_threshold": String(format: "%.2f", config.precipitationChanceThreshold), "gust_threshold_mps": config.gustThresholdMps.map { String(format: "%.2f", $0) } ?? "nil", ] do { let weather = try await service.weather(for: location) current = currentConditions(weather: weather) diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil" diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count) if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value { diagnostics["current_gust_mps"] = String(format: "%.2f", gust) } else { diagnostics["current_gust_mps"] = "nil" } rainSoon = rainSoonAlert(weather: weather, now: now) windAlert = windAlertInfo(weather: weather) diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new } } catch { let msg = String(describing: error) diagnostics["weatherkit_error"] = msg throw WeatherError.weatherKitFailed(message: msg, diagnostics: diagnostics) } let warnings = warnings(now: now) diagnostics["warnings_count"] = String(warnings.count) let data = WeatherData( current: current, rainSoon: rainSoon, windAlert: windAlert, warnings: warnings ) let output = Snapshot(data: data, diagnostics: diagnostics) return output } private func warnings(now: Int) -> [WeatherWarning] { struct MockWarning: Codable { let id: String let title: String let subtitle: String let ttlSec: Int? let confidence: Double? enum CodingKeys: String, CodingKey { case id case title case subtitle case ttlSec = "ttl_sec" case confidence } } _ = now guard let url = Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") else { return [] } guard let data = try? Data(contentsOf: url) else { return [] } guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] } return items.map { item in WeatherWarning( id: item.id, title: item.title.truncated(maxLength: TextConstraints.titleMax), subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), ttlSec: max(1, item.ttlSec ?? 3600), confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0) ) } } private func currentConditions(weather: Weather) -> WeatherData.Current { let tempC = weather.currentWeather.temperature.converted(to: .celsius).value let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value let tempInt = Int(tempC.rounded()) let feelsInt = Int(feelsC.rounded()) let conditionEnum = weather.currentWeather.condition return WeatherData.Current(temperatureC: tempInt, feelsLikeC: feelsInt, condition: conditionEnum) } private func rainSoonAlert(weather: Weather, now: Int) -> WeatherData.RainSoon? { let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) let lookahead = TimeInterval(config.rainLookaheadSec) if let minuteForecast = weather.minuteForecast { if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) { return WeatherData.RainSoon( startAt: Int(start.timeIntervalSince1970), ttlSec: config.rainTTL, source: .minutely ) } return nil } if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) { return WeatherData.RainSoon( startAt: Int(start.timeIntervalSince1970), ttlSec: config.rainTTL, source: .hourlyApprox ) } return nil } private func windAlertInfo(weather: Weather) -> WeatherData.WindAlert? { guard let gustThreshold = config.gustThresholdMps else { return nil } guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return nil } guard gust >= gustThreshold else { return nil } return WeatherData.WindAlert(gustMps: gust, thresholdMps: gustThreshold, ttlSec: config.windTTL) } private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? { for minute in minutes { guard minute.date >= now else { continue } let dt = minute.date.timeIntervalSince(now) guard dt <= lookahead else { break } let chance = minute.precipitationChance let isRainy = minute.precipitation == .rain || minute.precipitation == .mixed if isRainy, chance >= config.precipitationChanceThreshold { return minute.date } } return nil } private func firstRainStartHourly(_ hours: [HourWeather], now: Date, within lookahead: TimeInterval) -> Date? { for hour in hours { guard hour.date >= now else { continue } let dt = hour.date.timeIntervalSince(now) guard dt <= lookahead else { break } let chance = hour.precipitationChance let isRainy = hour.precipitation == .rain || hour.precipitation == .mixed if isRainy, chance >= config.precipitationChanceThreshold { return hour.date } } return nil } private func rainTitle(start: Date, now: Date) -> String { let minutes = max(0, Int(((start.timeIntervalSince(now)) / 60.0).rounded())) if minutes <= 0 { return "Rain now" } return "Rain in ~\(minutes) min" } private func rainDiagnostics(weather: Weather, now: Int) -> [String: String] { let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) let lookahead = TimeInterval(config.rainLookaheadSec) if let minuteForecast = weather.minuteForecast { var bestChance: Double = 0 var bestType: String = "none" var firstAnyPrecipOffsetSec: Int? = nil for minute in minuteForecast.forecast { guard minute.date >= nowDate else { continue } let dt = minute.date.timeIntervalSince(nowDate) guard dt <= lookahead else { break } if minute.precipitation != .none, firstAnyPrecipOffsetSec == nil { firstAnyPrecipOffsetSec = Int(dt.rounded()) } if minute.precipitationChance > bestChance { bestChance = minute.precipitationChance bestType = minute.precipitation.rawValue } } return [ "rain_resolution": "minutely", "rain_best_chance": String(format: "%.2f", bestChance), "rain_best_type": bestType, "rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil", ] } var bestChance: Double = 0 var bestType: String = "none" var firstAnyPrecipOffsetSec: Int? = nil for hour in weather.hourlyForecast.forecast { guard hour.date >= nowDate else { continue } let dt = hour.date.timeIntervalSince(nowDate) guard dt <= lookahead else { break } if hour.precipitation != .none, firstAnyPrecipOffsetSec == nil { firstAnyPrecipOffsetSec = Int(dt.rounded()) } if hour.precipitationChance > bestChance { bestChance = hour.precipitationChance bestType = hour.precipitation.rawValue } } return [ "rain_resolution": "hourly", "rain_best_chance": String(format: "%.2f", bestChance), "rain_best_type": bestType, "rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil", ] } }