initial commit
This commit is contained in:
334
IrisCompanion/iris/DataSources/WeatherDataSource.swift
Normal file
334
IrisCompanion/iris/DataSources/WeatherDataSource.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
//
|
||||
// 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user