Skip to content
This repository has been archived by the owner on Apr 2, 2023. It is now read-only.

Commit

Permalink
Add RVR Support (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
JonathanDowning authored Dec 3, 2020
1 parent 3bcb857 commit 901bdff
Show file tree
Hide file tree
Showing 2 changed files with 87 additions and 11 deletions.
66 changes: 64 additions & 2 deletions Sources/METAR/METAR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public struct METAR: Equatable {
public var skyCondition: SkyCondition?
public var cloudLayers: [CloudLayer] = []
public var visibility: Visibility?
public var runwayVisualRanges: [RunwayVisualRange] = []
public var weather: [Weather] = []
public var trends: [Forecast] = []
public var militaryColourCode: MilitaryColourCode?
Expand Down Expand Up @@ -123,6 +124,7 @@ public extension METAR {
guard let metricFractionVisibilityRegularExpression = try? NSRegularExpression(pattern: "(?<!\\S)(?:([0-9]+) )?([0-9]+)/([0-9]{1})SM\\b") else { return nil }
guard let weatherRegularExpression = try? NSRegularExpression(pattern: "(?<!\\S)(-|\\+|VC|RE)?([A-Z]{2})([A-Z]{2})?([A-Z]{2})?\\b") else { return nil }
guard let pressureRegularExpression = try? NSRegularExpression(pattern: "(?<!\\S)(?:(?:Q([0-9]{4}))|(?:A([0-9]{4})))\\b") else { return nil }
guard let rvrRegularExpression = try? NSRegularExpression(pattern: "(?<!\\S)R([0-9]{2}[L|C|R]?)\\/(?:(?:([P|M]?)([0-9]{4}))|(?:([0-9]{4})V([0-9]{4})))(FT)?(U|D|N)?\\b") else { return nil }

// Lone Slashes Removal

Expand Down Expand Up @@ -227,7 +229,7 @@ public extension METAR {
metar.removeSubrange(range)
}

trends = forecasts
trends = forecasts.reversed()

if let match = metar.matches(for: nosigRegularExpression).first, let range = match[0] {
noSignificantChangesExpected = true
Expand All @@ -236,6 +238,47 @@ public extension METAR {
noSignificantChangesExpected = false
}

// MARK: Runway Visual Range

var rvrs = [RunwayVisualRange]()
for match in metar.matches(for: rvrRegularExpression).reversed() {
guard let range = match[0], let runwayRange = match[1] else {
continue
}

let unit: UnitLength = match[6].map { metar[$0] } == "FT" ? .feet : .meters

let visibility: RunwayVisualRange.Visibility
if let lower = match[4].flatMap({ Double(String(metar[$0])) }), let upper = match[5].flatMap({ Double(String(metar[$0])) }) {
visibility = .variable(.init(value: min(lower, upper), unit: unit), .init(value: max(lower, upper), unit: unit))
} else if let value = match[3].flatMap({ Double(String(metar[$0])) }), match[2].map({ metar[$0] }) == "M" {
visibility = .lessThan(.init(value: value, unit: unit))
} else if let value = match[3].flatMap({ Double(String(metar[$0])) }), match[2].map({ metar[$0] }) == "P" {
visibility = .greaterThan(.init(value: value, unit: unit))
} else if let value = match[3].flatMap({ Double(String(metar[$0])) }) {
visibility = .equal(.init(value: value, unit: unit))
} else {
continue
}

let trend: RunwayVisualRange.Trend?
switch match[7].map({ metar[$0] }) {
case "U":
trend = .increasing
case "D":
trend = .decreasing
case "N":
trend = .notChanging
default:
trend = nil
}

rvrs.append(.init(runway: String(metar[runwayRange]), visibility: visibility, trend: trend))

metar.removeSubrange(range)
}
self.runwayVisualRanges = rvrs.reversed()

// MARK: Military Colour Code

if let match = metar.matches(for: militaryColorCodeRegularExpression).first, let range = match[0], let colourRange = match[1] {
Expand Down Expand Up @@ -597,6 +640,25 @@ public struct Visibility: Equatable {

}

public struct RunwayVisualRange: Equatable {

public enum Visibility: Equatable {
case lessThan(Measurement<UnitLength>)
case equal(Measurement<UnitLength>)
case greaterThan(Measurement<UnitLength>)
case variable(Measurement<UnitLength>, Measurement<UnitLength>)
}

public enum Trend {
case increasing, decreasing, notChanging
}

public var runway: String
public var visibility: Visibility
public var trend: Trend?

}

public struct Wind: Equatable {

public struct Speed: Equatable {
Expand Down Expand Up @@ -680,7 +742,7 @@ public struct CloudLayer: Equatable {

public struct Weather: Equatable {

public var modifier: Modifier
public var modifier: Modifier = .moderate
public var phenomena: [Phenomena] = []

public enum Modifier {
Expand Down
32 changes: 23 additions & 9 deletions Tests/METARTests/METARTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,32 @@ import XCTest

final class METARTests: XCTestCase {

func testDatelessMETAR() {
let metarString = "KRDU 22007KT 9SM FEW080 FEW250 22/20 A3004 RMK AO2 SLP166 60000 T02170200 10222 20206 53022"
let metar = METAR(rawMETAR: metarString)
XCTAssertNil(metar)
}

func testMETARs() throws {
try compareMETAR("KRDU COR 281151Z AUTO 22007KT 9SM SCT040TCU FEW080 FEW250 22/20 SHRA BLU A3004 NOSIG RMK AO2 SLP166 60000 T02170200 10222 20206 53022", "KRDU", 28, 11, 51, wind: Wind(direction: 220, speed: .init(value: 7, unit: .knots)), qnh: QNH(value: 30.04, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .scattered, height: .init(value: 4000, unit: .feet), significantCloudType: .toweringCumulus), .init(coverage: .few, height: .init(value: 8000, unit: .feet)), .init(coverage: .few, height: .init(value: 25000, unit: .feet))], visibility: .init(value: 9, unit: .miles), weather: [Weather(modifier: .moderate, phenomena: [.showers, .rain])], militaryColourCode: .blue, temperature: Temperature(value: 22), dewPoint: Temperature(value: 20), automaticStation: true, correction: true, noSignificantChangesExpected: true, remarks: "AO2 SLP166 60000 T02170200 10222 20206 53022", flightRules: .vfr)
func testValidMETARs() throws {
try compareMETAR("KRDU COR 281151Z AUTO 22007KT 9SM SCT040TCU FEW080 FEW250 22/20 SHRA BLU A3004 NOSIG RMK AO2 SLP166 60000 T02170200 10222 20206 53022", "KRDU", 28, 11, 51, wind: Wind(direction: 220, speed: .init(value: 7, unit: .knots)), qnh: QNH(value: 30.04, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .scattered, height: .init(value: 4000, unit: .feet), significantCloudType: .toweringCumulus), .init(coverage: .few, height: .init(value: 8000, unit: .feet)), .init(coverage: .few, height: .init(value: 25000, unit: .feet))], visibility: .init(value: 9, unit: .miles), weather: [Weather(phenomena: [.showers, .rain])], militaryColourCode: .blue, temperature: Temperature(value: 22), dewPoint: Temperature(value: 20), automaticStation: true, correction: true, noSignificantChangesExpected: true, remarks: "AO2 SLP166 60000 T02170200 10222 20206 53022", flightRules: .vfr)
try compareMETAR("EGGD 121212Z ///010 10SM", "EGGD", 12, 12, 12, cloudLayers: [.init(coverage: .notReported, height: .init(value: 1000, unit: .feet))], visibility: .init(value: 10, unit: .miles), flightRules: nil)
try compareMETAR("EGGD 121212Z ///010 1 1/4SM", "EGGD", 12, 12, 12, cloudLayers: [.init(coverage: .notReported, height: .init(value: 1000, unit: .feet))], visibility: .init(value: 1.25, unit: .miles), flightRules: nil)
try compareMETAR("EGGD 121212Z ///010/// 1 1/4SM", "EGGD", 12, 12, 12, cloudLayers: [.init(coverage: .notReported, height: .init(value: 1000, unit: .feet))], visibility: .init(value: 1.25, unit: .miles), flightRules: nil)
try compareMETAR("PGUA 160631Z 33024G55KT 0SM +RA VV000 23/22 A2891 RESHRA RMK WR//=", "PGUA", 16, 6, 31, wind: Wind(direction: 330, speed: .init(value: 24, unit: .knots), gustSpeed: .init(value: 55, unit: .knots)), qnh: QNH(value: 28.91, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .skyObscured, height: .init(value: 0, unit: .feet))], visibility: .init(value: 0, unit: .miles), weather: [.init(modifier: .heavy, phenomena: [.rain]), .init(modifier: .recent, phenomena: [.showers, .rain])], temperature: .init(value: 23), dewPoint: .init(value: 22), remarks: "WR//=", flightRules: .lifr)
try compareMETAR("EGGD 251250Z AUTO 25016G27KT 220V280 9999 BKN019///TCU 11/11 VCFG Q1013", "EGGD", 25, 12, 50, wind: Wind(direction: 250, speed: .init(value: 16, unit: .knots), gustSpeed: .init(value: 27, unit: .knots), variation: .init(from: 220, to: 280)),qnh: QNH(value: 1013, unit: .hectopascals), cloudLayers: [.init(coverage: .broken, height: .init(value: 1900, unit: .feet), significantCloudType: .toweringCumulus)], visibility: .init(value: 10, unit: .kilometers, greaterThanOrEqual: true), weather: [.init(modifier: .inTheVicinity, phenomena: [.fog])], temperature: .init(value: 11), dewPoint: .init(value: 11), automaticStation: true, flightRules: .mvfr)
try compareMETAR("UUDD 261100Z 25007MPS 200V290 9999 BKN026 14/08 Q0997 R88/290095 TEMPO 28012G18MPS 1500 TSRA BKN015CB RMK TEST REMARKS", "UUDD", 26, 11, 0, wind: Wind(direction: 250, speed: Wind.Speed(value: 7, unit: .metersPerSecond), gustSpeed: nil, variation: Wind.Variation(from: 200, to: 290)), qnh: QNH(value: 997, unit: .hectopascals), cloudLayers: [CloudLayer(coverage: .broken, height: .init(value: 2600, unit: .feet))], visibility: Visibility(value: 10, unit: .kilometers, greaterThanOrEqual: true), trends: [.init(metarRepresentation: .init(identifier: "UUDD", date: date(day: 26, hour: 11, minute: 00), wind: Wind(direction: 280, speed: .init(value: 12, unit: .metersPerSecond), gustSpeed: .init(value: 18, unit: .metersPerSecond)), cloudLayers: [.init(coverage: .broken, height: .init(value: 1500, unit: .feet), significantCloudType: .cumulonimbus)], visibility: .init(value: 1500, unit: .meters), weather: [.init(modifier: .moderate, phenomena: [.thunderstorm, .rain])], metarString: "28012G18MPS 1500 TSRA BKN015CB", flightRules: .mvfr), type: .temporaryForecast)], temperature: Temperature(value: 14), dewPoint: Temperature(value: 8), remarks: "TEST REMARKS", flightRules: .mvfr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18L/1600FT FG OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18L", visibility: .equal(.init(value: 1600, unit: .feet)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18R/1600FT FG OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 1600, unit: .feet)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18C/1600FT RA OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18C", visibility: .equal(.init(value: 1600, unit: .feet)))], weather: [.init(phenomena: [.rain])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18/1600FT FG OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18", visibility: .equal(.init(value: 1600, unit: .feet)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18R/M0600FT FG OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18R", visibility: .lessThan(.init(value: 600, unit: .feet)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00004KT 1/4SM R18R/P1000 FG OVC003 25/25 A2967", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 4, unit: .knots)), qnh: .init(value: 29.67, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .overcast, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.25, unit: .miles), rvrs: [.init(runway: "18R", visibility: .greaterThan(.init(value: 1000, unit: .meters)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 25), dewPoint: .init(value: 25), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0700V1000FT FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .variable(.init(value: 700, unit: .feet), .init(value: 1000, unit: .feet)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0400 FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 400, unit: .meters)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0400U FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 400, unit: .meters)), trend: .increasing)], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0400 FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 400, unit: .meters)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0400 FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 400, unit: .meters)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("KBLD 061212Z AUTO 00003KT 1/2SM R18R/0400D R22/M0300N FG BKN003 14/14 A3015", "KBLD", 6, 12, 12, wind: .init(direction: 0, speed: .init(value: 3, unit: .knots)), qnh: .init(value: 30.15, unit: .inchesOfMercury), cloudLayers: [.init(coverage: .broken, height: .init(value: 300, unit: .feet))], visibility: .init(value: 0.5, unit: .miles), rvrs: [.init(runway: "18R", visibility: .equal(.init(value: 400, unit: .meters)), trend: .decreasing), .init(runway: "22", visibility: .lessThan(.init(value: 300, unit: .meters)), trend: .notChanging)], weather: [.init(phenomena: [.fog])], temperature: .init(value: 14), dewPoint: .init(value: 14), automaticStation: true, flightRules: .lifr)
try compareMETAR("LRTC 032130Z AUTO 26003KT 230V300 1000 R34/0900V1400U FG SCT001 BKN042 02/02 Q1022", "LRTC", 3, 21, 30, wind: .init(direction: 260, speed: .init(value: 3, unit: .knots), variation: .init(from: 230, to: 300)), qnh: .init(value: 1022, unit: .hectopascals), cloudLayers: [.init(coverage: .scattered, height: .init(value: 100, unit: .feet)), .init(coverage: .broken, height: .init(value: 4200, unit: .feet))], visibility: .init(value: 1000, unit: .meters), rvrs: [.init(runway: "34", visibility: .variable(.init(value: 900, unit: .meters), .init(value: 1400, unit: .meters)), trend: .increasing)], weather: [.init(phenomena: [.fog])], temperature: .init(value: 2), dewPoint: .init(value: 2), automaticStation: true, flightRules: .lifr)
try compareMETAR("VIAR 032130Z 00000KT 0500 R34/1000 FG NSC 12/11 Q1013", "VIAR", 3, 21, 30, wind: .init(direction: 0, speed: .init(value: 0, unit: .knots)), qnh: .init(value: 1013, unit: .hectopascals), skyCondition: .noSignificantCloud, visibility: .init(value: 500, unit: .meters), rvrs: [.init(runway: "34", visibility: .equal(.init(value: 1000, unit: .meters)))], weather: [.init(phenomena: [.fog])], temperature: .init(value: 12), dewPoint: .init(value: 11), flightRules: .lifr)
}

func testInvalidMETARs() {
XCTAssertNil(METAR(rawMETAR: "KRDU 22007KT 9SM FEW080 FEW250 22/20 A3004 RMK AO2 SLP166 60000 T02170200 10222 20206 53022"))
}

func testLIFRVisibility() throws {
Expand Down Expand Up @@ -83,7 +96,7 @@ final class METARTests: XCTestCase {
try XCTAssertEqual(XCTUnwrap(METAR(rawMETAR: "EGGD 121212Z 9999")).ceilingAndVisibilityOK, false)
}

private func compareMETAR(_ rawMETAR: String, _ identifier: String, _ day: Int, _ hour: Int, _ minute: Int, wind: Wind? = nil, qnh: QNH? = nil, skyCondition: SkyCondition? = nil, cloudLayers: [CloudLayer] = [], visibility: Visibility? = nil, weather: [Weather] = [], trends: [Forecast] = [], militaryColourCode: MilitaryColourCode? = nil, temperature: Temperature? = nil, dewPoint: Temperature? = nil, ceilingAndVisibilityOK: Bool = false, automaticStation: Bool = false, correction: Bool = false, noSignificantChangesExpected: Bool = false, remarks: String? = nil, flightRules: NOAAFlightRules?) throws {
private func compareMETAR(_ rawMETAR: String, _ identifier: String, _ day: Int, _ hour: Int, _ minute: Int, wind: Wind? = nil, qnh: QNH? = nil, skyCondition: SkyCondition? = nil, cloudLayers: [CloudLayer] = [], visibility: Visibility? = nil, rvrs: [RunwayVisualRange] = [], weather: [Weather] = [], trends: [Forecast] = [], militaryColourCode: MilitaryColourCode? = nil, temperature: Temperature? = nil, dewPoint: Temperature? = nil, ceilingAndVisibilityOK: Bool = false, automaticStation: Bool = false, correction: Bool = false, noSignificantChangesExpected: Bool = false, remarks: String? = nil, flightRules: NOAAFlightRules?) throws {
try XCTAssertEqual(XCTUnwrap(METAR(rawMETAR: rawMETAR)), XCTUnwrap(METAR(
identifier: identifier,
date: date(day: day, hour: hour, minute: minute),
Expand All @@ -92,6 +105,7 @@ final class METARTests: XCTestCase {
skyCondition: skyCondition,
cloudLayers: cloudLayers,
visibility: visibility,
runwayVisualRanges: rvrs,
weather: weather,
trends: trends,
militaryColourCode: militaryColourCode,
Expand Down

0 comments on commit 901bdff

Please sign in to comment.