import Foundation import Vapor import SwiftTimeZoneLookup struct ApiQueryStartEndRanges { let daily: ClosedRange? let hourly: ClosedRange? let minutely_15: ClosedRange? } 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.. 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.. [(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, 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) case forecastDaysInvalid(given: Int, allowed: ClosedRange) case enddateMustBeLargerEqualsThanStartdate case dateOutOfRange(parameter: String, allowed: Range) 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 } }