refactor weather parser

This commit is contained in:
windyboy
2025-12-24 17:05:51 +08:00
parent d0fd461e38
commit dbde6524b3
5 changed files with 330 additions and 358 deletions
@@ -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))
}
}
-89
View File
@@ -14,92 +14,3 @@ func Tokenize(raw string) []string {
tokens := strings.Fields(raw) tokens := strings.Fields(raw)
return tokens 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
}
+55 -117
View File
@@ -14,87 +14,9 @@ func parseMetar(raw string, reportType string) (*weather.Metar, error) {
return nil, weather.ErrInvalidFormat return nil, weather.ErrInvalidFormat
} }
metar := &weather.Metar{ metar, pos, err := parseMetarHeader(tokens, raw, reportType)
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])
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse issue time: %w", err) return nil, 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++
}
}
}
} }
// Parse runway visual range (RVR) - skip for now, add to warnings // 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++ pos++
} }
// Parse weather phenomena phenomConsumed, phenomena := parsePhenomena(tokens[pos:])
for pos < len(tokens) { metar.Phenomena = append(metar.Phenomena, phenomena...)
if match := phenomenonPattern.FindStringSubmatch(tokens[pos]); match != nil { pos += phenomConsumed
phenom := weather.Phenomenon{
Intensity: match[1],
Descriptor: match[2],
Weather: match[3],
}
metar.Phenomena = append(metar.Phenomena, phenom)
pos++
} else {
break
}
}
// Parse clouds cloudConsumed, clouds := parseClouds(tokens[pos:])
for pos < len(tokens) { metar.Clouds = append(metar.Clouds, clouds...)
// Check for special cloud codes first pos += cloudConsumed
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
}
}
// Parse temperature/dewpoint // Parse temperature/dewpoint
if pos < len(tokens) { if pos < len(tokens) {
@@ -213,6 +105,53 @@ func parseMetar(raw string, reportType string) (*weather.Metar, error) {
return metar, nil 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 // parseWind parses wind information
func parseWind(token string) *weather.Wind { func parseWind(token string) *weather.Wind {
match := windPattern.FindStringSubmatch(token) match := windPattern.FindStringSubmatch(token)
@@ -302,4 +241,3 @@ func parseVisibility(token string) *weather.Visibility {
return vis return vis
} }
+102 -151
View File
@@ -15,56 +15,16 @@ func parseTaf(raw string) (*weather.Taf, error) {
return nil, weather.ErrInvalidFormat return nil, weather.ErrInvalidFormat
} }
taf := &weather.Taf{ taf, pos, err := parseTafHeader(tokens, raw)
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])
if err != nil { 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) // Parse main forecast period (before any FM/TEMPO/BECMG)
mainPeriod := weather.TafPeriod{ mainPeriod := weather.TafPeriod{
Type: "MAIN", Type: "MAIN",
ValidFrom: validFrom, ValidFrom: taf.ValidFrom,
ValidTo: validTo, ValidTo: taf.ValidTo,
} }
// Find first special section // Find first special section
@@ -83,8 +43,23 @@ func parseTaf(raw string) (*weather.Taf, error) {
// Parse main period tokens // Parse main period tokens
if firstSpecialIdx > pos { 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] mainTokens := tokens[pos:firstSpecialIdx]
parsePeriodElements(mainTokens, &mainPeriod, taf) parsePeriodElements(mainTokens, &mainPeriod, &taf.Warnings)
taf.Periods = append(taf.Periods, mainPeriod) taf.Periods = append(taf.Periods, mainPeriod)
pos = firstSpecialIdx pos = firstSpecialIdx
} }
@@ -102,7 +77,7 @@ func parseTaf(raw string) (*weather.Taf, error) {
pos++ pos++
case strings.HasPrefix(upperToken, "FM"): 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 { if err != nil {
taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse FM period: %v", err)) taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse FM period: %v", err))
pos++ pos++
@@ -116,7 +91,7 @@ func parseTaf(raw string) (*weather.Taf, error) {
pos = newPos pos = newPos
case strings.HasPrefix(upperToken, "TEMPO"): 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 { if err != nil {
taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse TEMPO period: %v", err)) taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse TEMPO period: %v", err))
pos++ pos++
@@ -130,7 +105,7 @@ func parseTaf(raw string) (*weather.Taf, error) {
pos = newPos pos = newPos
case strings.HasPrefix(upperToken, "BECMG"): 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 { if err != nil {
taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse BECMG period: %v", err)) taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse BECMG period: %v", err))
pos++ pos++
@@ -159,8 +134,60 @@ func parseTaf(raw string) (*weather.Taf, error) {
return taf, nil 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 // 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{ period := weather.TafPeriod{
Type: "FM", Type: "FM",
} }
@@ -174,9 +201,8 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath
hour, _ := strconv.Atoi(match[2]) hour, _ := strconv.Atoi(match[2])
min, _ := strconv.Atoi(match[3]) min, _ := strconv.Atoi(match[3])
year := issueTimeVal.Year() period.ValidFrom = resolveDayTime(validityFrom, day, hour, min)
month := issueTimeVal.Month() period.ValidTo = validityTo
period.ValidFrom = time.Date(year, month, day, hour, min, 0, 0, time.UTC)
// Find end of this period (next FM, TEMPO, BECMG, or end) // Find end of this period (next FM, TEMPO, BECMG, or end)
endPos := len(tokens) endPos := len(tokens)
@@ -193,7 +219,7 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath
// Parse period elements // Parse period elements
periodTokens := tokens[startPos+1 : endPos] 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 // Set valid_to to start of next period or end of validity
if endPos < len(tokens) { if endPos < len(tokens) {
@@ -204,7 +230,10 @@ func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weath
nextDay, _ := strconv.Atoi(match[1]) nextDay, _ := strconv.Atoi(match[1])
nextHour, _ := strconv.Atoi(match[2]) nextHour, _ := strconv.Atoi(match[2])
nextMin, _ := strconv.Atoi(match[3]) 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 // 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{ period := weather.TafPeriod{
Type: "TEMPO", Type: "TEMPO",
} }
@@ -228,16 +257,8 @@ func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we
toDay, _ := strconv.Atoi(match[3]) toDay, _ := strconv.Atoi(match[3])
toHour, _ := strconv.Atoi(match[4]) toHour, _ := strconv.Atoi(match[4])
year := issueTimeVal.Year() period.ValidFrom = resolveDayTime(validityFrom, fromDay, fromHour, 0)
month := issueTimeVal.Month() period.ValidTo = resolveDayTime(period.ValidFrom, toDay, toHour, 0)
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)
}
// Find end of this period // Find end of this period
endPos := startPos + 1 endPos := startPos + 1
@@ -254,13 +275,13 @@ func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we
// Parse period elements // Parse period elements
periodTokens := tokens[startPos+1 : endPos] periodTokens := tokens[startPos+1 : endPos]
parsePeriodElements(periodTokens, &period, nil) parsePeriodElements(periodTokens, &period, warnings)
return period, endPos, nil return period, endPos, nil
} }
// parseBECMGPeriod parses a BECMG (becoming) period // 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{ period := weather.TafPeriod{
Type: "BECMG", Type: "BECMG",
} }
@@ -275,16 +296,8 @@ func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we
toDay, _ := strconv.Atoi(match[3]) toDay, _ := strconv.Atoi(match[3])
toHour, _ := strconv.Atoi(match[4]) toHour, _ := strconv.Atoi(match[4])
year := issueTimeVal.Year() period.ValidFrom = resolveDayTime(validityFrom, fromDay, fromHour, 0)
month := issueTimeVal.Month() period.ValidTo = resolveDayTime(period.ValidFrom, toDay, toHour, 0)
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)
}
// Find end of this period // Find end of this period
endPos := startPos + 1 endPos := startPos + 1
@@ -301,86 +314,24 @@ func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (we
// Parse period elements // Parse period elements
periodTokens := tokens[startPos+1 : endPos] periodTokens := tokens[startPos+1 : endPos]
parsePeriodElements(periodTokens, &period, nil) parsePeriodElements(periodTokens, &period, warnings)
return period, endPos, nil return period, endPos, nil
} }
// parsePeriodElements parses common elements (wind, visibility, clouds, phenomena) for a TAF period // parsePeriodElements parses common elements (wind, visibility, clouds, phenomena) for a TAF period
func parsePeriodElements(tokens []string, period *weather.TafPeriod, taf *weather.Taf) { func parsePeriodElements(tokens []string, period *weather.TafPeriod, warnings *[]string) {
pos := 0 pos, wind, visibility := parseWindAndVisibility(tokens)
period.Wind = wind
period.Visibility = visibility
// Parse wind phenomConsumed, phenomena := parsePhenomena(tokens[pos:])
if pos < len(tokens) { period.Phenomena = append(period.Phenomena, phenomena...)
if wind := parseWind(tokens[pos]); wind != nil { pos += phenomConsumed
period.Wind = wind
pos++
// Check for variable wind cloudConsumed, clouds := parseClouds(tokens[pos:])
if pos < len(tokens) { period.Clouds = append(period.Clouds, clouds...)
if match := variableWindPattern.FindStringSubmatch(tokens[pos]); match != nil { pos += cloudConsumed
from, _ := strconv.Atoi(match[1])
to, _ := strconv.Atoi(match[2])
period.Wind.Variable = true
period.Wind.VariableFrom = from
period.Wind.VariableTo = to
pos++
}
}
}
}
// Parse visibility appendPeriodWarnings(warnings, tokens[pos:])
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++
}
}
} }
@@ -1,6 +1,7 @@
package weather package weather
import ( import (
domainweather "caatsm/internal/domain/weather"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@@ -24,6 +25,52 @@ var _ = Describe("TAF Parser", func() {
Expect(taf.Periods[1].Type).To(Equal("FM")) 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() { It("should parse TAF with TEMPO period", func() {
raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 TEMPO2512/2515 27015G25KT 5SM -RA=" raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 TEMPO2512/2515 27015G25KT 5SM -RA="
taf, err := parseTaf(raw) taf, err := parseTaf(raw)
@@ -57,6 +104,36 @@ var _ = Describe("TAF Parser", func() {
Expect(found).To(BeTrue()) 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() { It("should parse TAF with PROB", func() {
raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 PROB30 TEMPO2512/2515 27015G25KT 5SM -RA=" raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 PROB30 TEMPO2512/2515 27015G25KT 5SM -RA="
taf, err := parseTaf(raw) taf, err := parseTaf(raw)
@@ -95,4 +172,3 @@ var _ = Describe("TAF Parser", func() {
}) })
}) })
}) })