// // 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() {} } protocol WeatherWarningProviding: Sendable { func warningCandidates(location: CLLocation, now: Int) -> [Candidate] } struct NoopWeatherWarningProvider: WeatherWarningProviding { func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] } } /// Loads mock warning candidates from a local JSON file. /// /// File format: /// [ /// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } } /// ] struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable { struct MockWarning: Codable { let id: String let title: String let subtitle: String let ttlSec: Int? let confidence: Double? let meta: [String: String]? enum CodingKeys: String, CodingKey { case id case title case subtitle case ttlSec = "ttl_sec" case confidence case meta } } let url: URL? init(url: URL?) { self.url = url } func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { guard let url 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 Candidate( id: item.id, type: .weatherWarning, title: item.title.truncated(maxLength: TextConstraints.titleMax), subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0), createdAt: now, ttlSec: max(1, item.ttlSec ?? 3600), metadata: item.meta ) } } } @available(iOS 16.0, *) final class WeatherDataSource { private let service: WeatherService private let config: WeatherAlertConfig private let warningProvider: WeatherWarningProviding init(service: WeatherService = .shared, config: WeatherAlertConfig = .init(), warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider( url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") )) { self.service = service self.config = config self.warningProvider = warningProvider } /// Returns alert candidates derived from WeatherKit forecasts. func candidates(for location: CLLocation, now: Int) async -> [Candidate] { let result = await candidatesWithDiagnostics(for: location, now: now) return result.candidates } struct CandidatesResult: Sendable { let candidates: [Candidate] let weatherKitError: String? let diagnostics: [String: String] } func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult { var results: [Candidate] = [] var errorString: String? = 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) results.append(currentConditionsCandidate(weather: weather, location: location, now: now)) 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" } results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now)) results.append(contentsOf: windCandidates(weather: weather, location: location, now: now)) diagnostics["candidates_weatherkit"] = String(results.count) diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new } } catch { errorString = String(describing: error) diagnostics["weatherkit_error"] = errorString } results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now)) diagnostics["candidates_total"] = String(results.count) return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics) } private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate { 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 cond = weather.currentWeather.condition.description let conditionEnum = weather.currentWeather.condition let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax) let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax) return Candidate( id: "wx:now:\(now / 60)", type: .currentWeather, title: title, subtitle: subtitle, confidence: 0.8, createdAt: now, ttlSec: 1800, condition: conditionEnum, metadata: [ "source": "weatherkit_current", "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), ] ) } private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { 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 [ Candidate( id: "wx:rain:\(Int(start.timeIntervalSince1970))", type: .weatherAlert, title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax), confidence: 0.9, createdAt: now, ttlSec: config.rainTTL, metadata: [ "source": "weatherkit_minutely", "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), ] ) ] } return [] } if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) { return [ Candidate( id: "wx:rain:\(Int(start.timeIntervalSince1970))", type: .weatherAlert, title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax), subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax), confidence: 0.6, createdAt: now, ttlSec: config.rainTTL, metadata: [ "source": "weatherkit_hourly_approx", "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), ] ) ] } return [] } private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { guard let gustThreshold = config.gustThresholdMps else { return [] } guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] } guard gust >= gustThreshold else { return [] } let mph = Int((gust * 2.236936).rounded()) let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax) return [ Candidate( id: "wx:wind:\(now):\(Int(gustThreshold * 10))", type: .weatherAlert, title: title, subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax), confidence: 0.8, createdAt: now, ttlSec: config.windTTL, metadata: [ "source": "weatherkit_current", "gust_mps": String(format: "%.2f", gust), "threshold_mps": String(format: "%.2f", gustThreshold), "lat": String(format: "%.5f", location.coordinate.latitude), "lon": String(format: "%.5f", location.coordinate.longitude), ] ) ] } 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", ] } }