diff --git a/internal/adapter/parser/weather/common_elements.go b/internal/adapter/parser/weather/common_elements.go new file mode 100644 index 0000000..426c51f --- /dev/null +++ b/internal/adapter/parser/weather/common_elements.go @@ -0,0 +1,96 @@ +package weather + +import ( + "caatsm/internal/domain/weather" + "fmt" + "strconv" +) + +func parseWindAndVisibility(tokens []string) (int, *weather.Wind, *weather.Visibility) { + pos := 0 + var wind *weather.Wind + var visibility *weather.Visibility + + if pos < len(tokens) { + if parsed := parseWind(tokens[pos]); parsed != nil { + wind = parsed + pos++ + + if pos < len(tokens) { + if match := variableWindPattern.FindStringSubmatch(tokens[pos]); match != nil { + from, _ := strconv.Atoi(match[1]) + to, _ := strconv.Atoi(match[2]) + wind.Variable = true + wind.VariableFrom = from + wind.VariableTo = to + pos++ + } + } + } + } + + if pos < len(tokens) { + if parsed := parseVisibility(tokens[pos]); parsed != nil { + visibility = parsed + pos++ + } + } + + return pos, wind, visibility +} + +func parsePhenomena(tokens []string) (int, []weather.Phenomenon) { + pos := 0 + var phenomena []weather.Phenomenon + + for pos < len(tokens) { + if match := phenomenonPattern.FindStringSubmatch(tokens[pos]); match != nil { + phenomena = append(phenomena, weather.Phenomenon{ + Intensity: match[1], + Descriptor: match[2], + Weather: match[3], + }) + pos++ + continue + } + break + } + + return pos, phenomena +} + +func parseClouds(tokens []string) (int, []weather.Cloud) { + pos := 0 + var clouds []weather.Cloud + + if pos < len(tokens) { + if skyClearPattern.MatchString(tokens[pos]) { + return 1, clouds + } + } + + for pos < len(tokens) { + if match := cloudPattern.FindStringSubmatch(tokens[pos]); match != nil { + alt, _ := strconv.Atoi(match[2]) + clouds = append(clouds, weather.Cloud{ + Type: match[1], + Altitude: alt * 100, + Modifier: match[3], + }) + pos++ + continue + } + break + } + + return pos, clouds +} + +func appendPeriodWarnings(warnings *[]string, tokens []string) { + if warnings == nil { + return + } + for _, token := range tokens { + *warnings = append(*warnings, fmt.Sprintf("unrecognized token in period: %s", token)) + } +} diff --git a/internal/adapter/parser/weather/lexer.go b/internal/adapter/parser/weather/lexer.go index 40aae55..b333de2 100644 --- a/internal/adapter/parser/weather/lexer.go +++ b/internal/adapter/parser/weather/lexer.go @@ -14,92 +14,3 @@ func Tokenize(raw string) []string { tokens := strings.Fields(raw) return tokens } - -// Section represents a special section in the report (RMK, TEMPO, BECMG, FM) -type Section struct { - Type string // "RMK", "TEMPO", "BECMG", "FM" - StartIdx int // Starting token index - EndIdx int // Ending token index (exclusive) - Tokens []string // Tokens in this section -} - -// FindSpecialSections identifies special sections in the tokenized report -func FindSpecialSections(tokens []string) []Section { - var sections []Section - var currentSection *Section - - for i, token := range tokens { - upperToken := strings.ToUpper(token) - - switch { - case strings.HasPrefix(upperToken, "RMK"): - if currentSection != nil { - currentSection.EndIdx = i - sections = append(sections, *currentSection) - } - currentSection = &Section{ - Type: "RMK", - StartIdx: i, - Tokens: []string{token}, - } - - case strings.HasPrefix(upperToken, "TEMPO"): - if currentSection != nil && currentSection.Type != "RMK" { - currentSection.EndIdx = i - sections = append(sections, *currentSection) - } - currentSection = &Section{ - Type: "TEMPO", - StartIdx: i, - Tokens: []string{token}, - } - - case strings.HasPrefix(upperToken, "BECMG"): - if currentSection != nil && currentSection.Type != "RMK" { - currentSection.EndIdx = i - sections = append(sections, *currentSection) - } - currentSection = &Section{ - Type: "BECMG", - StartIdx: i, - Tokens: []string{token}, - } - - case strings.HasPrefix(upperToken, "FM"): - if currentSection != nil && currentSection.Type != "RMK" { - currentSection.EndIdx = i - sections = append(sections, *currentSection) - } - currentSection = &Section{ - Type: "FM", - StartIdx: i, - Tokens: []string{token}, - } - - default: - if currentSection != nil { - currentSection.Tokens = append(currentSection.Tokens, token) - } - } - } - - // Close the last section - if currentSection != nil { - currentSection.EndIdx = len(tokens) - sections = append(sections, *currentSection) - } - - return sections -} - -// ExtractSectionTokens extracts tokens for a specific section type -func ExtractSectionTokens(tokens []string, sectionType string) []string { - sections := FindSpecialSections(tokens) - for _, section := range sections { - if section.Type == sectionType { - return section.Tokens - } - } - return nil -} - diff --git a/internal/adapter/parser/weather/metar_parser.go b/internal/adapter/parser/weather/metar_parser.go index 34c23ad..56a9bf2 100644 --- a/internal/adapter/parser/weather/metar_parser.go +++ b/internal/adapter/parser/weather/metar_parser.go @@ -14,87 +14,9 @@ func parseMetar(raw string, reportType string) (*weather.Metar, error) { return nil, weather.ErrInvalidFormat } - metar := &weather.Metar{ - ReportType: weather.ReportType(reportType), - RawTextVal: raw, - Warnings: []string{}, - Clouds: []weather.Cloud{}, - Phenomena: []weather.Phenomenon{}, - } - - // Track current position in tokens - pos := 0 - - // Skip report type (METAR/SPECI) - first token - if pos >= len(tokens) { - return nil, weather.ErrMissingStation - } - pos++ - - // Parse station (second token) - if pos >= len(tokens) { - return nil, weather.ErrMissingStation - } - metar.StationID = tokens[pos] - pos++ - - // Parse issue time (DDHHmmZ) - third token - if pos >= len(tokens) { - return nil, weather.ErrMissingTime - } - issueTime, err := ParseTime(tokens[pos]) + metar, pos, err := parseMetarHeader(tokens, raw, reportType) if err != nil { - return nil, fmt.Errorf("failed to parse issue time: %w", err) - } - metar.IssueTimeVal = issueTime - metar.ObsTime = issueTime - pos++ - - // Check for modifier (AUTO, COR) - if pos < len(tokens) { - if match := modifierPattern.FindStringSubmatch(tokens[pos]); match != nil { - metar.Modifier = match[1] - pos++ - } - } - - // Parse wind - if pos < len(tokens) { - if wind := parseWind(tokens[pos]); wind != nil { - metar.Wind = wind - pos++ - - // Check for variable wind (e.g., 180V240) - if pos < len(tokens) { - if match := variableWindPattern.FindStringSubmatch(tokens[pos]); match != nil { - from, _ := strconv.Atoi(match[1]) - to, _ := strconv.Atoi(match[2]) - metar.Wind.Variable = true - metar.Wind.VariableFrom = from - metar.Wind.VariableTo = to - pos++ - } - } - } - } - - // Parse visibility - if pos < len(tokens) { - if vis := parseVisibility(tokens[pos]); vis != nil { - metar.Visibility = vis - pos++ - - // Check for directional visibility (e.g., 2000NE) - if pos < len(tokens) { - if match := directionalVisibilityPattern.FindStringSubmatch(tokens[pos]); match != nil { - dist, _ := strconv.ParseFloat(match[1], 64) - metar.Visibility.Distance = dist - metar.Visibility.Direction = match[2] - metar.Visibility.Unit = "M" - pos++ - } - } - } + return nil, err } // Parse runway visual range (RVR) - skip for now, add to warnings @@ -103,43 +25,13 @@ func parseMetar(raw string, reportType string) (*weather.Metar, error) { pos++ } - // Parse weather phenomena - for pos < len(tokens) { - if match := phenomenonPattern.FindStringSubmatch(tokens[pos]); match != nil { - phenom := weather.Phenomenon{ - Intensity: match[1], - Descriptor: match[2], - Weather: match[3], - } - metar.Phenomena = append(metar.Phenomena, phenom) - pos++ - } else { - break - } - } + phenomConsumed, phenomena := parsePhenomena(tokens[pos:]) + metar.Phenomena = append(metar.Phenomena, phenomena...) + pos += phenomConsumed - // Parse clouds - for pos < len(tokens) { - // Check for special cloud codes first - if match := skyClearPattern.FindStringSubmatch(tokens[pos]); match != nil { - // SKC, CLR, NSC - no clouds - pos++ - break - } - - if match := cloudPattern.FindStringSubmatch(tokens[pos]); match != nil { - alt, _ := strconv.Atoi(match[2]) - cloud := weather.Cloud{ - Type: match[1], - Altitude: alt * 100, // Convert to feet - Modifier: match[3], - } - metar.Clouds = append(metar.Clouds, cloud) - pos++ - } else { - break - } - } + cloudConsumed, clouds := parseClouds(tokens[pos:]) + metar.Clouds = append(metar.Clouds, clouds...) + pos += cloudConsumed // Parse temperature/dewpoint if pos < len(tokens) { @@ -213,6 +105,53 @@ func parseMetar(raw string, reportType string) (*weather.Metar, error) { return metar, nil } +func parseMetarHeader(tokens []string, raw string, reportType string) (*weather.Metar, int, error) { + metar := &weather.Metar{ + ReportType: weather.ReportType(reportType), + RawTextVal: raw, + Warnings: []string{}, + Clouds: []weather.Cloud{}, + Phenomena: []weather.Phenomenon{}, + } + + pos := 0 + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingStation + } + pos++ + + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingStation + } + metar.StationID = tokens[pos] + pos++ + + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingTime + } + issueTime, err := ParseTime(tokens[pos]) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse issue time: %w", err) + } + metar.IssueTimeVal = issueTime + metar.ObsTime = issueTime + pos++ + + if pos < len(tokens) { + if match := modifierPattern.FindStringSubmatch(tokens[pos]); match != nil { + metar.Modifier = match[1] + pos++ + } + } + + consumed, wind, visibility := parseWindAndVisibility(tokens[pos:]) + metar.Wind = wind + metar.Visibility = visibility + pos += consumed + + return metar, pos, nil +} + // parseWind parses wind information func parseWind(token string) *weather.Wind { match := windPattern.FindStringSubmatch(token) @@ -302,4 +241,3 @@ func parseVisibility(token string) *weather.Visibility { return vis } - diff --git a/internal/adapter/parser/weather/taf_parser.go b/internal/adapter/parser/weather/taf_parser.go index bca8cf3..566bb3c 100644 --- a/internal/adapter/parser/weather/taf_parser.go +++ b/internal/adapter/parser/weather/taf_parser.go @@ -15,56 +15,16 @@ func parseTaf(raw string) (*weather.Taf, error) { return nil, weather.ErrInvalidFormat } - taf := &weather.Taf{ - ReportType: weather.ReportTypeTAF, - RawTextVal: raw, - Warnings: []string{}, - Periods: []weather.TafPeriod{}, - } - - pos := 0 - - // Skip TAF - first token - if pos >= len(tokens) { - return nil, weather.ErrMissingStation - } - pos++ - - // Parse station (second token) - if pos >= len(tokens) { - return nil, weather.ErrMissingStation - } - taf.StationID = tokens[pos] - pos++ - - // Parse issue time (DDHHmmZ) - third token - if pos >= len(tokens) { - return nil, weather.ErrMissingTime - } - issueTime, err := ParseTime(tokens[pos]) + taf, pos, err := parseTafHeader(tokens, raw) if err != nil { - return nil, fmt.Errorf("failed to parse issue time: %w", err) + return nil, err } - taf.IssueTimeVal = issueTime - pos++ - - // Parse validity period (DDHH/DDHH) - if pos >= len(tokens) { - return nil, fmt.Errorf("missing validity period") - } - validFrom, validTo, err := ParseTAFValidity(tokens[pos], taf.IssueTimeVal) - if err != nil { - return nil, fmt.Errorf("failed to parse validity period: %w", err) - } - taf.ValidFrom = validFrom - taf.ValidTo = validTo - pos++ // Parse main forecast period (before any FM/TEMPO/BECMG) mainPeriod := weather.TafPeriod{ Type: "MAIN", - ValidFrom: validFrom, - ValidTo: validTo, + ValidFrom: taf.ValidFrom, + ValidTo: taf.ValidTo, } // Find first special section @@ -83,8 +43,23 @@ func parseTaf(raw string) (*weather.Taf, error) { // Parse main period tokens if firstSpecialIdx > pos { + if firstSpecialIdx < len(tokens) { + upperToken := strings.ToUpper(tokens[firstSpecialIdx]) + if strings.HasPrefix(upperToken, "FM") { + if match := tafFMPattern.FindStringSubmatch(tokens[firstSpecialIdx]); match != nil { + day, _ := strconv.Atoi(match[1]) + hour, _ := strconv.Atoi(match[2]) + min, _ := strconv.Atoi(match[3]) + fmStart := resolveDayTime(taf.ValidFrom, day, hour, min) + if fmStart.Before(mainPeriod.ValidTo) { + mainPeriod.ValidTo = fmStart + } + } + } + } + mainTokens := tokens[pos:firstSpecialIdx] - parsePeriodElements(mainTokens, &mainPeriod, taf) + parsePeriodElements(mainTokens, &mainPeriod, &taf.Warnings) taf.Periods = append(taf.Periods, mainPeriod) pos = firstSpecialIdx } @@ -102,7 +77,7 @@ func parseTaf(raw string) (*weather.Taf, error) { pos++ case strings.HasPrefix(upperToken, "FM"): - period, newPos, err := parseFMPeriod(tokens, pos, taf.IssueTimeVal) + period, newPos, err := parseFMPeriod(tokens, pos, taf.ValidFrom, taf.ValidTo, &taf.Warnings) if err != nil { taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse FM period: %v", err)) pos++ @@ -116,7 +91,7 @@ func parseTaf(raw string) (*weather.Taf, error) { pos = newPos case strings.HasPrefix(upperToken, "TEMPO"): - period, newPos, err := parseTEMPOPeriod(tokens, pos, taf.IssueTimeVal) + period, newPos, err := parseTEMPOPeriod(tokens, pos, taf.ValidFrom, &taf.Warnings) if err != nil { taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse TEMPO period: %v", err)) pos++ @@ -130,7 +105,7 @@ func parseTaf(raw string) (*weather.Taf, error) { pos = newPos case strings.HasPrefix(upperToken, "BECMG"): - period, newPos, err := parseBECMGPeriod(tokens, pos, taf.IssueTimeVal) + period, newPos, err := parseBECMGPeriod(tokens, pos, taf.ValidFrom, &taf.Warnings) if err != nil { taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse BECMG period: %v", err)) pos++ @@ -159,8 +134,60 @@ func parseTaf(raw string) (*weather.Taf, error) { return taf, nil } +func parseTafHeader(tokens []string, raw string) (*weather.Taf, int, error) { + taf := &weather.Taf{ + ReportType: weather.ReportTypeTAF, + RawTextVal: raw, + Warnings: []string{}, + Periods: []weather.TafPeriod{}, + } + + pos := 0 + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingStation + } + pos++ + + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingStation + } + taf.StationID = tokens[pos] + pos++ + + if pos >= len(tokens) { + return nil, 0, weather.ErrMissingTime + } + issueTime, err := ParseTime(tokens[pos]) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse issue time: %w", err) + } + taf.IssueTimeVal = issueTime + pos++ + + if pos >= len(tokens) { + return nil, 0, fmt.Errorf("missing validity period") + } + validFrom, validTo, err := ParseTAFValidity(tokens[pos], taf.IssueTimeVal) + if err != nil { + return nil, 0, fmt.Errorf("failed to parse validity period: %w", err) + } + taf.ValidFrom = validFrom + taf.ValidTo = validTo + pos++ + + return taf, pos, nil +} + +func resolveDayTime(base time.Time, day, hour, min int) time.Time { + t := time.Date(base.Year(), base.Month(), day, hour, min, 0, 0, time.UTC) + if day < base.Day() { + t = t.AddDate(0, 1, 0) + } + return t +} + // parseFMPeriod parses an FM (from) period -func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { +func parseFMPeriod(tokens []string, startPos int, validityFrom, validityTo time.Time, warnings *[]string) (weather.TafPeriod, int, error) { period := weather.TafPeriod{ Type: "FM", } @@ -174,9 +201,8 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath hour, _ := strconv.Atoi(match[2]) min, _ := strconv.Atoi(match[3]) - year := issueTimeVal.Year() - month := issueTimeVal.Month() - period.ValidFrom = time.Date(year, month, day, hour, min, 0, 0, time.UTC) + period.ValidFrom = resolveDayTime(validityFrom, day, hour, min) + period.ValidTo = validityTo // Find end of this period (next FM, TEMPO, BECMG, or end) endPos := len(tokens) @@ -193,7 +219,7 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath // Parse period elements periodTokens := tokens[startPos+1 : endPos] - parsePeriodElements(periodTokens, &period, nil) + parsePeriodElements(periodTokens, &period, warnings) // Set valid_to to start of next period or end of validity if endPos < len(tokens) { @@ -204,7 +230,10 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath nextDay, _ := strconv.Atoi(match[1]) nextHour, _ := strconv.Atoi(match[2]) nextMin, _ := strconv.Atoi(match[3]) - period.ValidTo = time.Date(year, month, nextDay, nextHour, nextMin, 0, 0, time.UTC) + nextStart := resolveDayTime(period.ValidFrom, nextDay, nextHour, nextMin) + if nextStart.Before(period.ValidTo) { + period.ValidTo = nextStart + } } } } @@ -213,7 +242,7 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath } // parseTEMPOPeriod parses a TEMPO (temporary) period -func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { +func parseTEMPOPeriod(tokens []string, startPos int, validityFrom time.Time, warnings *[]string) (weather.TafPeriod, int, error) { period := weather.TafPeriod{ Type: "TEMPO", } @@ -228,16 +257,8 @@ func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we toDay, _ := strconv.Atoi(match[3]) toHour, _ := strconv.Atoi(match[4]) - year := issueTimeVal.Year() - month := issueTimeVal.Month() - - period.ValidFrom = time.Date(year, month, fromDay, fromHour, 0, 0, 0, time.UTC) - period.ValidTo = time.Date(year, month, toDay, toHour, 0, 0, 0, time.UTC) - - // If toDay < fromDay, assume next month - if toDay < fromDay { - period.ValidTo = period.ValidTo.AddDate(0, 1, 0) - } + period.ValidFrom = resolveDayTime(validityFrom, fromDay, fromHour, 0) + period.ValidTo = resolveDayTime(period.ValidFrom, toDay, toHour, 0) // Find end of this period endPos := startPos + 1 @@ -254,13 +275,13 @@ func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we // Parse period elements periodTokens := tokens[startPos+1 : endPos] - parsePeriodElements(periodTokens, &period, nil) + parsePeriodElements(periodTokens, &period, warnings) return period, endPos, nil } // parseBECMGPeriod parses a BECMG (becoming) period -func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { +func parseBECMGPeriod(tokens []string, startPos int, validityFrom time.Time, warnings *[]string) (weather.TafPeriod, int, error) { period := weather.TafPeriod{ Type: "BECMG", } @@ -275,16 +296,8 @@ func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we toDay, _ := strconv.Atoi(match[3]) toHour, _ := strconv.Atoi(match[4]) - year := issueTimeVal.Year() - month := issueTimeVal.Month() - - period.ValidFrom = time.Date(year, month, fromDay, fromHour, 0, 0, 0, time.UTC) - period.ValidTo = time.Date(year, month, toDay, toHour, 0, 0, 0, time.UTC) - - // If toDay < fromDay, assume next month - if toDay < fromDay { - period.ValidTo = period.ValidTo.AddDate(0, 1, 0) - } + period.ValidFrom = resolveDayTime(validityFrom, fromDay, fromHour, 0) + period.ValidTo = resolveDayTime(period.ValidFrom, toDay, toHour, 0) // Find end of this period endPos := startPos + 1 @@ -301,86 +314,24 @@ func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we // Parse period elements periodTokens := tokens[startPos+1 : endPos] - parsePeriodElements(periodTokens, &period, nil) + parsePeriodElements(periodTokens, &period, warnings) return period, endPos, nil } // parsePeriodElements parses common elements (wind, visibility, clouds, phenomena) for a TAF period -func parsePeriodElements(tokens []string, period *weather.TafPeriod, taf *weather.Taf) { - pos := 0 +func parsePeriodElements(tokens []string, period *weather.TafPeriod, warnings *[]string) { + pos, wind, visibility := parseWindAndVisibility(tokens) + period.Wind = wind + period.Visibility = visibility - // Parse wind - if pos < len(tokens) { - if wind := parseWind(tokens[pos]); wind != nil { - period.Wind = wind - pos++ + phenomConsumed, phenomena := parsePhenomena(tokens[pos:]) + period.Phenomena = append(period.Phenomena, phenomena...) + pos += phenomConsumed - // Check for variable wind - if pos < len(tokens) { - if match := variableWindPattern.FindStringSubmatch(tokens[pos]); match != nil { - from, _ := strconv.Atoi(match[1]) - to, _ := strconv.Atoi(match[2]) - period.Wind.Variable = true - period.Wind.VariableFrom = from - period.Wind.VariableTo = to - pos++ - } - } - } - } + cloudConsumed, clouds := parseClouds(tokens[pos:]) + period.Clouds = append(period.Clouds, clouds...) + pos += cloudConsumed - // Parse visibility - if pos < len(tokens) { - if vis := parseVisibility(tokens[pos]); vis != nil { - period.Visibility = vis - pos++ - } - } - - // Parse weather phenomena - for pos < len(tokens) { - if match := phenomenonPattern.FindStringSubmatch(tokens[pos]); match != nil { - phenom := weather.Phenomenon{ - Intensity: match[1], - Descriptor: match[2], - Weather: match[3], - } - period.Phenomena = append(period.Phenomena, phenom) - pos++ - } else { - break - } - } - - // Parse clouds - for pos < len(tokens) { - if match := skyClearPattern.FindStringSubmatch(tokens[pos]); match != nil { - // SKC, CLR, NSC - no clouds - pos++ - break - } - - if match := cloudPattern.FindStringSubmatch(tokens[pos]); match != nil { - alt, _ := strconv.Atoi(match[2]) - cloud := weather.Cloud{ - Type: match[1], - Altitude: alt * 100, // Convert to feet - Modifier: match[3], - } - period.Clouds = append(period.Clouds, cloud) - pos++ - } else { - break - } - } - - // Collect any remaining unrecognized tokens as warnings - if taf != nil { - for pos < len(tokens) { - taf.Warnings = append(taf.Warnings, fmt.Sprintf("unrecognized token in period: %s", tokens[pos])) - pos++ - } - } + appendPeriodWarnings(warnings, tokens[pos:]) } - diff --git a/internal/adapter/parser/weather/taf_parser_test.go b/internal/adapter/parser/weather/taf_parser_test.go index 24ac189..32bdbf6 100644 --- a/internal/adapter/parser/weather/taf_parser_test.go +++ b/internal/adapter/parser/weather/taf_parser_test.go @@ -1,6 +1,7 @@ package weather import ( + domainweather "caatsm/internal/domain/weather" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -24,6 +25,52 @@ var _ = Describe("TAF Parser", func() { Expect(taf.Periods[1].Type).To(Equal("FM")) }) + It("should set final FM validTo to the TAF validity end", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 FM251800 36015KT 10SM SCT030=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + var fmPeriod *domainweather.TafPeriod + for i := range taf.Periods { + if taf.Periods[i].Type == "FM" { + fmPeriod = &taf.Periods[i] + break + } + } + Expect(fmPeriod).ToNot(BeNil()) + Expect(fmPeriod.ValidTo).To(BeTemporally("==", taf.ValidTo)) + }) + + It("should truncate main period at the first FM", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 FM251800 36015KT 10SM SCT030=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + var fmPeriod *domainweather.TafPeriod + for i := range taf.Periods { + if taf.Periods[i].Type == "FM" { + fmPeriod = &taf.Periods[i] + break + } + } + Expect(fmPeriod).ToNot(BeNil()) + Expect(taf.Periods[0].Type).To(Equal("MAIN")) + Expect(taf.Periods[0].ValidTo).To(BeTemporally("==", fmPeriod.ValidFrom)) + }) + + It("should roll FM into next month when day precedes validity start", func() { + raw := "TAF KJFK 301200Z 3012/0112 35012KT 10SM FEW020 FM010600 36015KT 10SM SCT030=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + var fmPeriod *domainweather.TafPeriod + for i := range taf.Periods { + if taf.Periods[i].Type == "FM" { + fmPeriod = &taf.Periods[i] + break + } + } + Expect(fmPeriod).ToNot(BeNil()) + Expect(fmPeriod.ValidFrom).To(BeTemporally(">", taf.ValidFrom)) + }) + It("should parse TAF with TEMPO period", func() { raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 TEMPO2512/2515 27015G25KT 5SM -RA=" taf, err := parseTaf(raw) @@ -57,6 +104,36 @@ var _ = Describe("TAF Parser", func() { Expect(found).To(BeTrue()) }) + It("should roll TEMPO into next month when day precedes validity start", func() { + raw := "TAF KJFK 301200Z 3012/0112 35012KT 10SM FEW020 TEMPO0102/0106 5SM -RA=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + var tempoPeriod *domainweather.TafPeriod + for i := range taf.Periods { + if taf.Periods[i].Type == "TEMPO" { + tempoPeriod = &taf.Periods[i] + break + } + } + Expect(tempoPeriod).ToNot(BeNil()) + Expect(tempoPeriod.ValidFrom).To(BeTemporally(">", taf.ValidFrom)) + }) + + It("should roll BECMG into next month when day precedes validity start", func() { + raw := "TAF KJFK 301200Z 3012/0112 35012KT 10SM FEW020 BECMG0102/0106 36015KT=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + var becmgPeriod *domainweather.TafPeriod + for i := range taf.Periods { + if taf.Periods[i].Type == "BECMG" { + becmgPeriod = &taf.Periods[i] + break + } + } + Expect(becmgPeriod).ToNot(BeNil()) + Expect(becmgPeriod.ValidFrom).To(BeTemporally(">", taf.ValidFrom)) + }) + It("should parse TAF with PROB", func() { raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 PROB30 TEMPO2512/2515 27015G25KT 5SM -RA=" taf, err := parseTaf(raw) @@ -95,4 +172,3 @@ var _ = Describe("TAF Parser", func() { }) }) }) -