301 lines
11 KiB
Swift
301 lines
11 KiB
Swift
//
|
|
// 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",
|
|
]
|
|
}
|
|
}
|