Spaces:
Sleeping
Sleeping
File size: 28,378 Bytes
6ee917b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 | import Foundation
import Vapor
import SwiftTimeZoneLookup
struct ApiQueryStartEndRanges {
let daily: ClosedRange<Timestamp>?
let hourly: ClosedRange<Timestamp>?
let minutely_15: ClosedRange<Timestamp>?
}
extension ClosedRange where Element == Timestamp {
/// Convert closed range to an openrange with delta time in seconds
func toRange(dt: Int) -> TimerangeDt {
TimerangeDt(range: lowerBound ..< upperBound.add(dt), dtSeconds: dt)
}
}
/// Option to overwrite the temporal output resolution instead of always getting 1-hourly data.
enum ApiTemporalResolution: String, Codable {
case native
case hourly
case hourly_1
case hourly_3
case hourly_6
var dtSeconds: Int? {
switch self {
case .native:
return nil
case .hourly, .hourly_1:
return 3600
case .hourly_3:
return 3*3600
case .hourly_6:
return 6*3600
}
}
}
/// All API parameter that are accepted and decoded via GET
struct ApiQueryParameter: Content, ApiUnitsSelectable {
let latitude: [String]
let longitude: [String]
let minutely_15: [String]?
/// Select individual variables for current weather
let current: [String]?
let hourly: [String]?
let daily: [String]?
/// For seasonal forecast
let six_hourly: [String]?
let current_weather: Bool?
let elevation: [String]?
let location_id: [String]?
let timezone: [String]?
let temperature_unit: TemperatureUnit?
let windspeed_unit: WindspeedUnit?
let wind_speed_unit: WindspeedUnit?
let precipitation_unit: PrecipitationUnit?
let length_unit: LengthUnit?
let timeformat: Timeformat?
let temporal_resolution: ApiTemporalResolution?
let bounding_box: [String]
let past_days: Int?
let past_hours: Int?
let past_minutely_15: Int?
let forecast_days: Int?
let forecast_hours: Int?
let forecast_minutely_15: Int?
/// If forecast_hours is set, the default is to start from the current hour. With `initial_hours`, a different hout of the day can be selected
/// E.g. initial_hours=0 and forecast_hours=12 would return the first 12 hours of the current day.
let initial_hours: Int?
let initial_minutely_15: Int?
let format: ForecastResultFormat?
let models: [String]?
let cell_selection: GridSelectionMode?
let apikey: String?
/// Tilt of a solar panel for GTI calculation. 0° horizontal, 90° vertical.
let tilt: Float?
/// Azimuth of a solar panel for GTI calculation. 0° south, -90° east, 90° west
let azimuth: Float?
/// Used in climate API
let disable_bias_correction: Bool? // CMIP
// Used in flood API
let ensemble: Bool // Glofas
/// In Air Quality API
let domains: CamsQuery.Domain? // sams
/// iso starting date `2022-02-01`
let start_date: [String]
/// included end date `2022-06-01`
let end_date: [String]
/// iso starting date `2022-02-01T00:00`
let start_hour: [String]
/// included end date `2022-06-01T23:00`
let end_hour: [String]
/// iso starting date `2022-02-01T00:00`
let start_minutely_15: [String]
/// included end date `2022-06-01T23:45`
let end_minutely_15: [String]
var timeformatOrDefault: Timeformat {
return timeformat ?? .iso8601
}
var readerOptions: GenericReaderOptions {
return GenericReaderOptions(tilt: tilt, azimuth: azimuth)
}
/// Parse `start_date` and `end_date` parameter to range of timestamps
func getStartEndDates() throws -> [ApiQueryStartEndRanges] {
let dates = try IsoDate.loadRange(start: start_date, end: end_date)
let hourRange = try IsoDateTime.loadRange(start: start_hour, end: end_hour)
let minutely15Range = try IsoDateTime.loadRange(start: start_minutely_15, end: end_minutely_15)
if dates.isEmpty, hourRange.isEmpty, minutely15Range.isEmpty {
return []
}
if let past_days, past_days != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
if let forecast_days, forecast_days != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
if let past_hours, past_hours != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
if let forecast_hours, forecast_hours != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
if let past_minutely_15, past_minutely_15 != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
if let forecast_minutely_15, forecast_minutely_15 != 0 {
throw ForecastapiError.pastDaysParameterNotAllowedWithStartEndRange
}
let count = max(max(dates.count, hourRange.count), minutely15Range.count)
return (0..<count).map {
ApiQueryStartEndRanges(
daily: $0 < dates.count ? dates[$0] : nil,
hourly: $0 < hourRange.count ? hourRange[$0] : nil,
minutely_15: $0 < minutely15Range.count ? minutely15Range[$0] : nil)
}
}
enum ApiRequestGeometry {
case coordinates([CoordinatesAndTimeZonesAndDates])
case boundingBox(BoundingBoxWGS84, dates: [ApiQueryStartEndRanges], timezone: TimezoneWithOffset)
}
/// Reads coordinates, elevation, timezones and start/end dataparameter and prepares an array.
/// For each element, an API response object will be returned later
func prepareCoordinates(allowTimezones: Bool) throws -> ApiRequestGeometry {
let dates = try getStartEndDates()
if let bb = try getBoundingBox() {
let timezones = allowTimezones ? try TimeZoneOrAuto.load(commaSeparatedOptional: timezone) ?? [] : []
guard timezones.count <= 1 else {
throw ForecastapiError.generic(message: "Only one timezone may be specified with bounding box queries")
}
let timezone: TimezoneWithOffset = try timezones.first.map({
switch $0 {
case .auto:
throw ForecastapiError.generic(message: "Timezone 'auto' not supported with bounding box queries")
case .timezone(let t):
return TimezoneWithOffset(timezone: t)
}
}) ?? TimezoneWithOffset.gmt
return .boundingBox(bb, dates: dates, timezone: timezone)
}
let coordinates = try getCoordinatesWithTimezone(allowTimezones: allowTimezones)
/// If no start/end dates are set, leav it `nil`
guard dates.count > 0 else {
return .coordinates(coordinates.map({
CoordinatesAndTimeZonesAndDates(coordinate: $0.coordinate, timezone: $0.timezone, startEndDate: nil)
}))
}
/// Multiple coordinates, but one start/end date. Return the same date range for each coordinate
if dates.count == 1 {
return .coordinates(coordinates.map({
CoordinatesAndTimeZonesAndDates(coordinate: $0.coordinate, timezone: $0.timezone, startEndDate: dates[0])
}))
}
/// Single coordinate, but multiple dates. Return different date ranges, but always the same coordinate
if coordinates.count == 1 {
return .coordinates(dates.map {
CoordinatesAndTimeZonesAndDates(coordinate: coordinates[0].coordinate, timezone: coordinates[0].timezone, startEndDate: $0)
})
}
guard dates.count == coordinates.count else {
throw ForecastapiError.coordinatesAndStartEndDatesCountMustBeTheSame
}
return .coordinates(zip(coordinates, dates).map {
CoordinatesAndTimeZonesAndDates(coordinate: $0.0.coordinate, timezone: $0.0.timezone, startEndDate: $0.1)
})
}
/// Parse `&bounding_box=` parameter. Format: lat1, lon1, lat2, lon2
func getBoundingBox() throws -> BoundingBoxWGS84? {
let coordinates = try Float.load(commaSeparated: self.bounding_box)
guard coordinates.count > 0 else {
return nil
}
guard coordinates.count == 4 else {
throw ForecastapiError.generic(message: "Parameter bounding_box must have 4 values")
}
let lat1 = coordinates[0]
let lon1 = coordinates[1]
let lat2 = coordinates[2]
let lon2 = coordinates[3]
guard lat1 < lat2 else {
throw ForecastapiError.generic(message: "The first latitude must be smaller than the second latitude")
}
guard (-90...90).contains(lat1), (-90...90).contains(lat2) else {
throw ForecastapiError.generic(message: "Latitudes must be between -90 and 90")
}
guard lon1 < lon2 else {
throw ForecastapiError.generic(message: "The first longitude must be smaller than the second longitude")
}
guard (-180...180).contains(lon1), (-180...180).contains(lon2) else {
throw ForecastapiError.generic(message: "Longitudes must be between -180 and 180")
}
return BoundingBoxWGS84(latitude: lat1..<lat2, longitude: lon1..<lon2)
}
/// Reads coordinates and timezone fields
/// If only one timezone is given, use the same timezone for all coordinates
/// Throws errors on invalid coordinates, timezones or invalid counts
private func getCoordinatesWithTimezone(allowTimezones: Bool) throws -> [(coordinate: CoordinatesAndElevation, timezone: TimezoneWithOffset)] {
let coordinates = try getCoordinates()
let timezones = allowTimezones ? try TimeZoneOrAuto.load(commaSeparatedOptional: timezone) : nil
guard let timezones else {
// if no timezone is specified, use GMT for all locations
return coordinates.map {
($0, .gmt)
}
}
if timezones.count == 1 {
return try coordinates.map {
($0, try timezones[0].resolve(coordinate: $0))
}
}
guard timezones.count == coordinates.count else {
throw ForecastapiError.latitudeAndLongitudeCountMustBeTheSame
}
return try zip(coordinates, timezones).map {
($0, try $1.resolve(coordinate: $0))
}
}
/// Parse latitude, longitude and elevation arrays to an array of coordinates
/// If no elevation is provided, a DEM is used to resolve the elevation
/// Throws errors on invalid coordinate ranges
private func getCoordinates() throws -> [CoordinatesAndElevation] {
let latitude = try Float.load(commaSeparated: self.latitude)
let longitude = try Float.load(commaSeparated: self.longitude)
let elevation = try Float.load(commaSeparatedOptional: self.elevation)
let locationIds = try Int.load(commaSeparatedOptional: self.location_id)
guard latitude.count == longitude.count else {
throw ForecastapiError.latitudeAndLongitudeCountMustBeTheSame
}
if let locationIds {
guard locationIds.count == longitude.count else {
throw ForecastapiError.latitudeAndLongitudeCountMustBeTheSame
}
}
if let elevation {
guard elevation.count == longitude.count else {
throw ForecastapiError.coordinatesAndElevationCountMustBeTheSame
}
return try zip(latitude, zip(longitude, elevation)).enumerated().map({
try CoordinatesAndElevation(
latitude: $0.element.0,
longitude: $0.element.1.0,
locationId: locationIds?[$0.offset] ?? $0.offset,
elevation: $0.element.1.1
)
})
}
return try zip(latitude, longitude).enumerated().map({
try CoordinatesAndElevation(
latitude: $0.element.0,
longitude: $0.element.1,
locationId: locationIds?[$0.offset] ?? $0.offset,
elevation: nil
)
})
}
func getTimerange2(timezone: TimezoneWithOffset, current: Timestamp, forecastDaysDefault: Int, forecastDaysMax: Int, startEndDate: ApiQueryStartEndRanges?, allowedRange: Range<Timestamp>, pastDaysMax: Int, forecastDaysMinutely15Default: Int = 3) throws -> ForecastApiTimeRange {
let actualUtcOffset = timezone.utcOffsetSeconds
/// Align data to nearest hour -> E.g. timezones in india may have 15 minutes offsets
let utcOffset = (actualUtcOffset / 3600) * 3600
if let startEndDate {
// Start and end data parameter have been set
let daily = startEndDate.daily?.toRange(dt: 86400) ?? TimerangeDt(start: current, nTime: 0, dtSeconds: 86400)
let hourly = startEndDate.hourly?.toRange(dt: 3600) ?? daily.with(dtSeconds: 3600)
let minutely_15 = startEndDate.minutely_15?.toRange(dt: 900) ?? hourly.with(dtSeconds: 900)
guard allowedRange.contains(daily.range.lowerBound) else {
throw ForecastapiError.dateOutOfRange(parameter: "start_date", allowed: allowedRange)
}
guard allowedRange.contains(daily.range.upperBound.add(-1 * daily.dtSeconds)) else {
throw ForecastapiError.dateOutOfRange(parameter: "end_date", allowed: allowedRange)
}
guard allowedRange.contains(hourly.range.lowerBound) else {
throw ForecastapiError.dateOutOfRange(parameter: "start_hourly", allowed: allowedRange)
}
guard allowedRange.contains(hourly.range.upperBound.add(-1 * hourly.dtSeconds)) else {
throw ForecastapiError.dateOutOfRange(parameter: "end_hourly", allowed: allowedRange)
}
guard allowedRange.contains(minutely_15.range.lowerBound) else {
throw ForecastapiError.dateOutOfRange(parameter: "start_minutely_15", allowed: allowedRange)
}
guard allowedRange.contains(minutely_15.range.upperBound.add(-1 * minutely_15.dtSeconds)) else {
throw ForecastapiError.dateOutOfRange(parameter: "end_minutely_15", allowed: allowedRange)
}
return ForecastApiTimeRange(
dailyDisplay: daily.add(-1 * actualUtcOffset),
dailyRead: daily.add(-1 * utcOffset),
hourlyDisplay: hourly.add(-1 * actualUtcOffset),
hourlyRead: hourly.add(-1 * utcOffset),
minutely15: minutely_15.add(-1 * actualUtcOffset)
)
}
// Evaluate any forecast_xxx, past_xxx parameter or fallback to default time
let daily = try Self.forecastTimeRange2(currentTime: current, utcOffset: utcOffset, pastSteps: past_days, forecastSteps: forecast_days, pastStepsMax: pastDaysMax, forecastStepsMax: forecastDaysMax, forecastStepsDefault: forecastDaysDefault, initialStep: nil, dtSeconds: 86400) ?? Self.forecastTimeRange2(currentTime: current, utcOffset: utcOffset, pastSteps: 0, forecastSteps: forecastDaysDefault, initialStep: nil, dtSeconds: 86400)
// Falls back to daily range as well
let hourly = try Self.forecastTimeRange2(currentTime: current, utcOffset: utcOffset, pastSteps: past_hours, forecastSteps: forecast_hours, pastStepsMax: pastDaysMax * 24, forecastStepsMax: forecastDaysMax * 24, forecastStepsDefault: forecastDaysMax*24, initialStep: initial_hours, dtSeconds: 3600) ?? daily.with(dtSeconds: 3600)
// May default back to 3 day forecast
let minutely_15 = try Self.forecastTimeRange2(currentTime: current, utcOffset: utcOffset, pastSteps: past_minutely_15, forecastSteps: forecast_minutely_15, pastStepsMax: pastDaysMax * 24 * 4, forecastStepsMax: forecastDaysMax * 24 * 4, forecastStepsDefault: forecastDaysMinutely15Default * 24 * 4, initialStep: initial_minutely_15, dtSeconds: 900) ?? Self.forecastTimeRange2(currentTime: current, utcOffset: utcOffset, pastSteps: past_days ?? 0, forecastSteps: forecast_days ?? forecastDaysMinutely15Default, initialStep: nil, dtSeconds: 86400).with(dtSeconds: 900)
return ForecastApiTimeRange(
dailyDisplay: daily.add(-1 * actualUtcOffset),
dailyRead: daily.add(-1 * utcOffset),
hourlyDisplay: hourly.add(-1 * actualUtcOffset),
hourlyRead: hourly.add(-1 * utcOffset),
minutely15: minutely_15.add(-1 * actualUtcOffset)
)
}
/// Return an aligned timerange for a local-time 7 day forecast. Timestamps are in UTC time. UTC offset has not been subtracted.
public static func forecastTimeRange2(currentTime: Timestamp, utcOffset: Int,pastSteps: Int, forecastSteps: Int, initialStep: Int?, dtSeconds: Int) -> TimerangeDt {
let pastSeconds = pastSteps * dtSeconds
let start: Int
if let initialStep {
// Align start to a specified hour per day
start = ((currentTime.timeIntervalSince1970 + utcOffset) / 86400) * 86400 + initialStep * dtSeconds
} else {
// Align start to current hour or current 15 minutely step (default)
start = ((currentTime.timeIntervalSince1970 + utcOffset) / dtSeconds) * dtSeconds
}
let end = start + forecastSteps * dtSeconds
return TimerangeDt(range: Timestamp(start - pastSeconds) ..< Timestamp(end), dtSeconds: dtSeconds)
}
/// Return an aligned timerange for a local-time 7 day forecast. Timestamps are in UTC time. UTC offset has not been subtracted.
public static func forecastTimeRange2(currentTime: Timestamp, utcOffset: Int,pastSteps: Int?, forecastSteps: Int?, pastStepsMax: Int, forecastStepsMax: Int, forecastStepsDefault: Int, initialStep: Int?, dtSeconds: Int) throws -> TimerangeDt? {
if pastSteps == nil && forecastSteps == nil {
return nil
}
let pastSteps = pastSteps ?? 0
let forecastSteps = forecastSteps ?? forecastStepsDefault
if forecastSteps < 0 || forecastSteps > forecastStepsMax {
throw ForecastapiError.forecastDaysInvalid(given: forecastStepsMax, allowed: 0...forecastStepsMax)
}
if pastSteps < 0 || pastSteps > pastStepsMax {
throw ForecastapiError.pastDaysInvalid(given: pastSteps, allowed: 0...pastStepsMax)
}
return Self.forecastTimeRange2(currentTime: currentTime, utcOffset: utcOffset, pastSteps: pastSteps, forecastSteps: forecastSteps, initialStep: initialStep, dtSeconds: dtSeconds)
}
}
struct ForecastApiTimeRange {
/// Time displayed in output. May contains 15 shifts due to 15 minute timezone offsets
let dailyDisplay: TimerangeDt
/// Time actually read in data
let dailyRead: TimerangeDt
/// Time displayed in output. May contains 15 shifts due to 15 minute timezone offsets
let hourlyDisplay: TimerangeDt
/// Time actually read in data
let hourlyRead: TimerangeDt
let minutely15: TimerangeDt
}
enum ForecastapiError: Error {
case latitudeMustBeInRangeOfMinus90to90(given: Float)
case longitudeMustBeInRangeOfMinus180to180(given: Float)
case pastDaysInvalid(given: Int, allowed: ClosedRange<Int>)
case forecastDaysInvalid(given: Int, allowed: ClosedRange<Int>)
case enddateMustBeLargerEqualsThanStartdate
case dateOutOfRange(parameter: String, allowed: Range<Timestamp>)
case startAndEnddataMustBeSpecified
case invalidTimezone
case timezoneNotSupported
case noDataAvilableForThisLocation
case pastDaysParameterNotAllowedWithStartEndRange
case forecastDaysParameterNotAllowedWithStartEndRange
case latitudeAndLongitudeSameCount
case latitudeAndLongitudeNotEmpty
case latitudeAndLongitudeMaximum(max: Int)
case latitudeAndLongitudeCountMustBeTheSame
case locationIdCountMustBeTheSame
case startAndEndDateCountMustBeTheSame
case coordinatesAndStartEndDatesCountMustBeTheSame
case coordinatesAndElevationCountMustBeTheSame
case generic(message: String)
case cannotReturnModelsWithDiffernetTimeIntervals
}
extension ForecastapiError: AbortError {
var status: HTTPResponseStatus {
return .badRequest
}
var reason: String {
switch self {
case .latitudeMustBeInRangeOfMinus90to90(given: let given):
return "Latitude must be in range of -90 to 90°. Given: \(given)."
case .longitudeMustBeInRangeOfMinus180to180(given: let given):
return "Longitude must be in range of -180 to 180°. Given: \(given)."
case .pastDaysInvalid(given: let given, allowed: let allowed):
return "Past days is invalid. Allowed range \(allowed.lowerBound) to \(allowed.upperBound). Given \(given)."
case .forecastDaysInvalid(given: let given, allowed: let allowed):
return "Forecast days is invalid. Allowed range \(allowed.lowerBound) to \(allowed.upperBound). Given \(given)."
case .invalidTimezone:
return "Invalid timezone"
case .enddateMustBeLargerEqualsThanStartdate:
return "End-date must be larger or equals than start-date"
case .dateOutOfRange(let paramater, let allowed):
return "Parameter '\(paramater)' is out of allowed range from \(allowed.lowerBound.iso8601_YYYY_MM_dd) to \(allowed.upperBound.add(-86400).iso8601_YYYY_MM_dd)"
case .startAndEnddataMustBeSpecified:
return "Both 'start_date' and 'end_date' must be set in the url"
case .pastDaysParameterNotAllowedWithStartEndRange:
return "Parameter 'past_days' is mutually exclusive with 'start_date' and 'end_date'"
case .forecastDaysParameterNotAllowedWithStartEndRange:
return "Parameter 'forecast_days' is mutually exclusive with 'start_date' and 'end_date'"
case .timezoneNotSupported:
return "This API does not yet support timezones"
case .latitudeAndLongitudeSameCount:
return "Parameter 'latitude' and 'longitude' must have the same amount of elements"
case .latitudeAndLongitudeNotEmpty:
return "Parameter 'latitude' and 'longitude' must not be empty"
case .latitudeAndLongitudeMaximum(max: let max):
return "Parameter 'latitude' and 'longitude' must not exceed \(max) coordinates."
case .latitudeAndLongitudeCountMustBeTheSame:
return "Parameter 'latitude' and 'longitude' must have the same number of elements"
case .locationIdCountMustBeTheSame:
return "Parameter 'location_id' and coordinates must have the same number of elements"
case .noDataAvilableForThisLocation:
return "No data is available for this location"
case .startAndEndDateCountMustBeTheSame:
return "Parameter 'start_date' and 'end_date' must have the same number of elements"
case .coordinatesAndStartEndDatesCountMustBeTheSame:
return "Parameter 'start_date' and 'end_date' must have the same number of elements as coordinates"
case .coordinatesAndElevationCountMustBeTheSame:
return "Parameter 'elevation' must have the same number of elements as coordinates"
case .generic(message: let message):
return message
case .cannotReturnModelsWithDiffernetTimeIntervals:
return "Cannot return models with different time-intervals"
}
}
}
/// Resolve coordinates and timezone
struct CoordinatesAndElevation {
let latitude: Float
let longitude: Float
let elevation: Float
let locationId: Int
/// If elevation is `nil` it will resolve it from DEM. If `NaN` it stays `NaN`.
init(latitude: Float, longitude: Float, locationId: Int, elevation: Float? = .nan) throws {
if latitude > 90 || latitude < -90 || latitude.isNaN {
throw ForecastapiError.latitudeMustBeInRangeOfMinus90to90(given: latitude)
}
if longitude > 180 || longitude < -180 || longitude.isNaN {
throw ForecastapiError.longitudeMustBeInRangeOfMinus180to180(given: longitude)
}
self.latitude = latitude
self.longitude = longitude
self.elevation = try elevation ?? Dem90.read(lat: latitude, lon: longitude)
self.locationId = locationId
}
}
struct CoordinatesAndTimeZonesAndDates {
let coordinate: CoordinatesAndElevation
let timezone: TimezoneWithOffset
let startEndDate: ApiQueryStartEndRanges?
}
enum Timeformat: String, Codable {
case iso8601
case unixtime
var unit: SiUnit {
switch self {
case .iso8601:
return .iso8601
case .unixtime:
return .unixTime
}
}
}
/// Differentiate between a user defined timezone or `auto` which is later resolved using coordinates
enum TimeZoneOrAuto {
/// User specified `auto`
case auto
/// User specified valid timezone
case timezone(TimeZone)
/// Take a string array which contains timezones or `auto`. Does an additional decoding step to split coma separated timezones.
/// Throws errors on invalid timezones
static func load(commaSeparatedOptional: [String]?) throws -> [TimeZoneOrAuto]? {
return try commaSeparatedOptional.map {
try $0.flatMap { s in
try s.split(separator: ",").map { timezone in
if timezone == "auto" {
return .auto
}
return .timezone(try TimeZone.initWithFallback(String(timezone)))
}
}
}
}
/// Given a coordinate, resolve auto timezone if required
func resolve(coordinate: CoordinatesAndElevation) throws -> TimezoneWithOffset {
switch self {
case .auto:
return try .init(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
case .timezone(let timezone):
return .init(timezone: timezone)
}
}
}
struct TimezoneWithOffset {
/// The actual utc offset. Not adjusted to the next full hour.
let utcOffsetSeconds: Int
/// Identifier like `Europe/Berlin`
let identifier: String
/// Abbreviation like `CEST`
let abbreviation: String
fileprivate static let timezoneDatabase = try! SwiftTimeZoneLookup(databasePath: "./Resources/SwiftTimeZoneLookup_SwiftTimeZoneLookup.resources/")
public init(utcOffsetSeconds: Int, identifier: String, abbreviation: String) {
self.utcOffsetSeconds = utcOffsetSeconds
self.identifier = identifier
self.abbreviation = abbreviation
}
public init(timezone: TimeZone) {
self.utcOffsetSeconds = timezone.secondsFromGMT()
self.identifier = timezone.identifier
self.abbreviation = timezone.abbreviation() ?? ""
}
public init(latitude: Float, longitude: Float) throws {
guard let identifier = TimezoneWithOffset.timezoneDatabase.simple(latitude: latitude, longitude: longitude) else {
throw ForecastapiError.invalidTimezone
}
self.init(timezone: try TimeZone.initWithFallback(identifier))
}
static let gmt = TimezoneWithOffset(utcOffsetSeconds: 0, identifier: "GMT", abbreviation: "GMT")
}
extension TimeZone {
static func initWithFallback(_ identifier: String) throws -> TimeZone {
// Some older timezone databases may still use the old name for Kyiv
if identifier == "Europe/Kyiv", let tz = TimeZone(identifier: "Europe/Kiev") {
return tz
}
if identifier == "America/Nuuk", let tz = TimeZone(identifier: "America/Godthab") {
return tz
}
guard let tz = TimeZone(identifier: identifier) else {
if identifier == "America/Ciudad_Juarez", let tz = TimeZone(identifier: "America/Mexico_City") {
return tz
}
throw ForecastapiError.invalidTimezone
}
return tz
}
}
|