diff --git a/README.md b/README.md index da11196..0318c7b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Clean Architecture with clear separation of concerns: - OpenTelemetry tracing and Prometheus metrics - Optional AFTN protocol validation - Batch processing and health monitoring +- Weather report parsing (METAR, SPECI, TAF) ## Prerequisites diff --git a/docs/dev-guide.md b/docs/dev-guide.md index 6866c4d..049a0ec 100644 --- a/docs/dev-guide.md +++ b/docs/dev-guide.md @@ -58,7 +58,7 @@ Use these tasks if you prefer a one-command workflow instead of invoking `docker ## Publishing Sample Telegrams -Use the helper CLI in `cmd/seed-telegrams` to push realistic payloads onto NATS (mirrors the fixtures in `internal/adapter/parser/aviation_parser_test.go`): +Use the helper CLI in `cmd/seed-telegrams` to push realistic payloads onto NATS. The tool supports both aviation telegrams (FPL, ARR, DEP, etc.) and weather reports (METAR, SPECI, TAF). ### Publishing to JetStream (Recommended) diff --git a/docs/weather-parser.md b/docs/weather-parser.md new file mode 100644 index 0000000..5b91c09 --- /dev/null +++ b/docs/weather-parser.md @@ -0,0 +1,121 @@ +# Weather Parser Documentation + +## Overview + +The weather parser module provides parsing capabilities for aviation weather reports including METAR, SPECI, and TAF messages. It is integrated into the system using a composite parser pattern that routes weather reports to the weather parser while maintaining backward compatibility with existing aviation telegram parsing. + +## Architecture + +The weather parser follows Clean Architecture principles: + +- **Domain Layer** (`internal/domain/weather/`): Core domain types and interfaces +- **Port Layer** (`internal/port/weather_parser.go`): Parser interface definition +- **Adapter Layer** (`internal/adapter/parser/weather/`): Parser implementation +- **Composite Parser** (`internal/adapter/parser/composite.go`): Routes messages to appropriate parser + +## Supported Report Types + +### METAR (Aviation Routine Weather Report) +Standard hourly weather observations from airports. + +**Example:** +``` +METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013= +``` + +### SPECI (Aviation Selected Special Weather Report) +Special weather observations issued when conditions change significantly. + +**Example:** +``` +SPECI KORD 251215Z 27015G25KT 5SM -RA BKN030 OVC050 20/18 A2992= +``` + +### TAF (Terminal Aerodrome Forecast) +Forecast weather conditions for airports, typically valid for 24-30 hours. + +**Example:** +``` +TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 FM251800 36015KT 10SM SCT030= +``` + +## Parsed Elements + +### Core Elements + +- **Station**: 4-letter ICAO airport code +- **Time**: Issue/observation time (DDHHmmZ format) +- **Wind**: Direction, speed, gusts, variable conditions +- **Visibility**: Distance, unit (meters or statute miles), directional visibility +- **Clouds**: Type (FEW/SCT/BKN/OVC/VV), altitude, modifiers (CB/TCU) +- **Temperature/Dewpoint**: Temperature in Celsius +- **Altimeter**: Pressure setting (QNH in hPa or A in inHg) +- **Weather Phenomena**: Intensity, descriptors, weather codes + +### TAF-Specific Elements + +- **Validity Period**: Forecast valid from/to times +- **Periods**: Main forecast, FM (from), TEMPO (temporary), BECMG (becoming) +- **Probability**: PROB30, PROB40 for uncertain conditions + +## Error Handling + +The parser uses a lenient approach: + +- **Unrecognized tokens**: Recorded in `warnings` array, parsing continues +- **Missing required fields**: Returns appropriate domain errors +- **Invalid format**: Returns `ErrInvalidFormat` + +This ensures that partial parsing is possible even when some elements are not recognized. + +## Usage + +The weather parser is automatically integrated via the composite parser. No special configuration is required. + +### Message Flow + +1. Raw message received +2. Composite parser checks if message is a weather report +3. If weather report: parsed by weather parser +4. If not: parsed by aviation parser (existing behavior) +5. Parsed result stored in `telegrams` table with `category` = "METAR"/"SPECI"/"TAF" +6. Structured data stored in `body_data` JSONB field + +### Database Storage + +Weather reports are stored in the existing `telegrams` table: + +- `category`: "METAR", "SPECI", or "TAF" +- `body_data`: JSONB containing structured weather data +- `content`: Original raw text +- `message_id`: Generated as `{station}-{issue_time}` + +## Testing + +Test files are located in `internal/adapter/parser/weather/`: + +- `classifier_test.go`: Tests report type classification +- `metar_parser_test.go`: Tests METAR/SPECI parsing +- `taf_parser_test.go`: Tests TAF parsing +- `composite_test.go`: Tests composite parser routing + +Run tests: +```bash +go test ./internal/adapter/parser/weather/... -v +``` + +## Limitations and Future Enhancements + +Current implementation covers core METAR/TAF elements. Future enhancements may include: + +- Runway Visual Range (RVR) parsing +- More comprehensive weather phenomenon codes +- Enhanced TAF period parsing +- Additional METAR modifiers +- Station metadata integration + +## References + +- [ICAO Annex 3: Meteorological Service for International Air Navigation](https://www.icao.int/safety/meteorology/pages/annex-3.aspx) +- [WMO Manual on Codes](https://library.wmo.int/index.php?lvl=notice_display&id=13617) + diff --git a/internal/adapter/parser/composite.go b/internal/adapter/parser/composite.go new file mode 100644 index 0000000..90a49c0 --- /dev/null +++ b/internal/adapter/parser/composite.go @@ -0,0 +1,69 @@ +package parser + +import ( + "caatsm/internal/adapter/dto" + "caatsm/internal/domain/weather" + "caatsm/internal/port" + "fmt" + "time" + + "github.com/google/uuid" +) + +// CompositeParser combines multiple parsers (weather and aviation) +type CompositeParser struct { + aviationParser Parser + weatherParser port.WeatherParser +} + +// NewCompositeParser creates a new composite parser +func NewCompositeParser(aviation Parser, weather port.WeatherParser) *CompositeParser { + return &CompositeParser{ + aviationParser: aviation, + weatherParser: weather, + } +} + +// Parse attempts to parse using multiple parsers +func (p *CompositeParser) Parse(rawText string) (*dto.ParsedTelegram, error) { + // 1. Try weather parser first + if p.weatherParser != nil && p.weatherParser.CanParse(rawText) { + wMsg, err := p.weatherParser.Parse(rawText) + if err == nil { + return p.weatherToTelegram(wMsg, rawText), nil + } + // If parsing fails, continue to aviation parser as fallback + } + + // 2. Try aviation parser (existing logic) + return p.aviationParser.Parse(rawText) +} + +// weatherToTelegram converts a WeatherMessage to ParsedTelegram +func (p *CompositeParser) weatherToTelegram(wMsg weather.WeatherMessage, raw string) *dto.ParsedTelegram { + parsed := dto.NewParsedTelegram() + parsed.Content = raw + parsed.Body = raw + parsed.Category = string(wMsg.Type()) + parsed.BodyData = wMsg + parsed.Parsed = true + parsed.Status = dto.MessageStatusParsed + parsed.Uuid = uuid.New().String() + parsed.ReceivedAt = time.Now() + parsed.ParsedAt = time.Now() + + // Extract basic information from weather message + switch msg := wMsg.(type) { + case *weather.Metar: + issueTime := msg.IssueTime() + parsed.MessageID = fmt.Sprintf("%s-%s", msg.Station(), issueTime.Format("20060102150405")) + parsed.DateTime = issueTime.Format("060102150405") + case *weather.Taf: + issueTime := msg.IssueTime() + parsed.MessageID = fmt.Sprintf("%s-%s", msg.Station(), issueTime.Format("20060102150405")) + parsed.DateTime = issueTime.Format("060102150405") + } + + return parsed +} + diff --git a/internal/adapter/parser/composite_test.go b/internal/adapter/parser/composite_test.go new file mode 100644 index 0000000..3c84c71 --- /dev/null +++ b/internal/adapter/parser/composite_test.go @@ -0,0 +1,80 @@ +package parser + +import ( + "caatsm/internal/adapter/dto" + weatherparser "caatsm/internal/adapter/parser/weather" + "caatsm/internal/port" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CompositeParser", func() { + var composite *CompositeParser + var aviationParser Parser + var weatherParser port.WeatherParser + + BeforeEach(func() { + aviationParser = &AviationParser{} + weatherParser = weatherparser.NewWeatherParser() + composite = NewCompositeParser(aviationParser, weatherParser) + }) + + Describe("Parse", func() { + It("should route METAR to weather parser", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013=" + parsed, err := composite.Parse(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).ToNot(BeNil()) + Expect(parsed.Category).To(Equal("METAR")) + Expect(parsed.Parsed).To(BeTrue()) + Expect(parsed.Status).To(Equal(dto.MessageStatusParsed)) + }) + + It("should route SPECI to weather parser", func() { + raw := "SPECI KORD 251215Z 27015G25KT 5SM -RA BKN030 OVC050 20/18 A2992=" + parsed, err := composite.Parse(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).ToNot(BeNil()) + Expect(parsed.Category).To(Equal("SPECI")) + Expect(parsed.Parsed).To(BeTrue()) + }) + + It("should route TAF to weather parser", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020=" + parsed, err := composite.Parse(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).ToNot(BeNil()) + Expect(parsed.Category).To(Equal("TAF")) + Expect(parsed.Parsed).To(BeTrue()) + }) + + It("should route aviation messages to aviation parser", func() { + raw := `ZCZC TMQ2617 142150 +GG ZBTJZPZX +150551 ZBTJUOBK +(FPL-OKA2861-IS +-MA60/M-SHID/C +-ZBTJ0030 +-K0420S0450 CG J1 FZ +-ZSYT0100 ZSQD ZYTL +-DOF/241215 EET/ZPKM0012 REG/B00FA PER/C) +NNNN` + parsed, err := composite.Parse(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).ToNot(BeNil()) + Expect(parsed.Category).To(Equal("FPL")) + Expect(parsed.Parsed).To(BeTrue()) + }) + + It("should handle weather reports without ending =", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013" + // Should fall back to aviation parser + parsed, err := composite.Parse(raw) + // May fail or succeed depending on aviation parser + _ = parsed + _ = err + }) + }) +}) + diff --git a/internal/adapter/parser/provider.go b/internal/adapter/parser/provider.go index d4664f3..c0017ae 100644 --- a/internal/adapter/parser/provider.go +++ b/internal/adapter/parser/provider.go @@ -1,6 +1,9 @@ package parser -import "caatsm/internal/adapter/dto" +import ( + "caatsm/internal/adapter/dto" + "caatsm/internal/port" +) // AviationParser implements the Parser interface type AviationParser struct{} @@ -10,8 +13,9 @@ func (p *AviationParser) Parse(rawText string) (*dto.ParsedTelegram, error) { return Parse(rawText) } -// ProvideParser creates a parser instance -func ProvideParser() Parser { - return &AviationParser{} +// ProvideParser creates a composite parser instance that combines weather and aviation parsers +func ProvideParser(weatherParser port.WeatherParser) Parser { + aviation := &AviationParser{} + return NewCompositeParser(aviation, weatherParser) } diff --git a/internal/adapter/parser/weather/classifier.go b/internal/adapter/parser/weather/classifier.go new file mode 100644 index 0000000..25b62a7 --- /dev/null +++ b/internal/adapter/parser/weather/classifier.go @@ -0,0 +1,44 @@ +package weather + +import ( + "regexp" + "strings" +) + +var ( + // metarPattern matches METAR or SPECI at the start followed by station code + metarPattern = regexp.MustCompile(`^(METAR|SPECI)\s+[A-Z0-9]{4}`) + // tafPattern matches TAF at the start followed by station code + tafPattern = regexp.MustCompile(`^TAF\s+[A-Z0-9]{4}`) +) + +// Classify identifies the weather report type from raw text +// Returns the report type (METAR, SPECI, or TAF) and true if it's a weather report +func Classify(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + + // Check for METAR/SPECI + if metarPattern.MatchString(raw) { + if strings.HasPrefix(raw, "SPECI") { + return "SPECI", true + } + return "METAR", true + } + + // Check for TAF + if tafPattern.MatchString(raw) { + return "TAF", true + } + + return "", false +} + +// HasValidEnding checks if the report ends with '=' +func HasValidEnding(raw string) bool { + raw = strings.TrimSpace(raw) + return strings.HasSuffix(raw, "=") +} + diff --git a/internal/adapter/parser/weather/classifier_test.go b/internal/adapter/parser/weather/classifier_test.go new file mode 100644 index 0000000..95ec904 --- /dev/null +++ b/internal/adapter/parser/weather/classifier_test.go @@ -0,0 +1,53 @@ +package weather + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Classifier", func() { + Describe("Classify", func() { + It("should identify METAR reports", func() { + reportType, ok := Classify("METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013=") + Expect(ok).To(BeTrue()) + Expect(reportType).To(Equal("METAR")) + }) + + It("should identify SPECI reports", func() { + reportType, ok := Classify("SPECI KORD 251215Z 27015G25KT 5SM -RA BKN030 OVC050 20/18 A2992=") + Expect(ok).To(BeTrue()) + Expect(reportType).To(Equal("SPECI")) + }) + + It("should identify TAF reports", func() { + reportType, ok := Classify("TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020=") + Expect(ok).To(BeTrue()) + Expect(reportType).To(Equal("TAF")) + }) + + It("should return false for non-weather reports", func() { + _, ok := Classify("(FPL-JAE7433-IS") + Expect(ok).To(BeFalse()) + }) + + It("should return false for empty string", func() { + _, ok := Classify("") + Expect(ok).To(BeFalse()) + }) + }) + + Describe("HasValidEnding", func() { + It("should return true for reports ending with =", func() { + Expect(HasValidEnding("METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013=")).To(BeTrue()) + }) + + It("should return false for reports without =", func() { + Expect(HasValidEnding("METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013")).To(BeFalse()) + }) + + It("should handle whitespace", func() { + Expect(HasValidEnding("METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013= ")).To(BeTrue()) + }) + }) +}) + diff --git a/internal/adapter/parser/weather/lexer.go b/internal/adapter/parser/weather/lexer.go new file mode 100644 index 0000000..40aae55 --- /dev/null +++ b/internal/adapter/parser/weather/lexer.go @@ -0,0 +1,105 @@ +package weather + +import "strings" + +// Tokenize splits the raw text into tokens by whitespace +func Tokenize(raw string) []string { + raw = strings.TrimSpace(raw) + // Remove trailing '=' if present + if strings.HasSuffix(raw, "=") { + raw = raw[:len(raw)-1] + raw = strings.TrimSpace(raw) + } + + 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 new file mode 100644 index 0000000..34c23ad --- /dev/null +++ b/internal/adapter/parser/weather/metar_parser.go @@ -0,0 +1,305 @@ +package weather + +import ( + "caatsm/internal/domain/weather" + "fmt" + "strconv" + "strings" +) + +// parseMetar parses a METAR or SPECI report +func parseMetar(raw string, reportType string) (*weather.Metar, error) { + tokens := Tokenize(raw) + if len(tokens) < 3 { + 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]) + 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++ + } + } + } + } + + // Parse runway visual range (RVR) - skip for now, add to warnings + for pos < len(tokens) && strings.HasPrefix(tokens[pos], "R") { + metar.Warnings = append(metar.Warnings, fmt.Sprintf("RVR not parsed: %s", tokens[pos])) + 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 + } + } + + // 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 + } + } + + // Parse temperature/dewpoint + if pos < len(tokens) { + if match := tempPattern.FindStringSubmatch(tokens[pos]); match != nil { + tempVal, _ := strconv.ParseFloat(match[2], 64) + if match[1] == "M" { + tempVal = -tempVal + } + + dewVal, _ := strconv.ParseFloat(match[4], 64) + if match[3] == "M" { + dewVal = -dewVal + } + + metar.Temperature = &weather.Temperature{ + Value: tempVal, + Unit: "C", + } + metar.Dewpoint = &weather.Temperature{ + Value: dewVal, + Unit: "C", + } + pos++ + } + } + + // Parse altimeter + if pos < len(tokens) { + if match := altimeterPattern.FindStringSubmatch(tokens[pos]); match != nil { + value, _ := strconv.ParseFloat(match[2], 64) + unit := match[1] + + switch unit { + case "Q": + // QNH in hPa + metar.Altimeter = &weather.Altimeter{ + Value: value, + Unit: "QNH", + } + case "A": + // Altimeter in inHg + metar.Altimeter = &weather.Altimeter{ + Value: value / 100.0, // A2992 means 29.92 inHg + Unit: "A", + } + } + pos++ + } + } + + // Parse remarks (everything after RMK) + remarksStart := -1 + for i := pos; i < len(tokens); i++ { + if strings.HasPrefix(strings.ToUpper(tokens[i]), "RMK") { + remarksStart = i + break + } + } + + if remarksStart >= 0 { + metar.Remarks = strings.Join(tokens[remarksStart:], " ") + pos = len(tokens) // Skip remaining tokens + } + + // Collect any remaining unrecognized tokens as warnings + for pos < len(tokens) { + metar.Warnings = append(metar.Warnings, fmt.Sprintf("unrecognized token: %s", tokens[pos])) + pos++ + } + + return metar, nil +} + +// parseWind parses wind information +func parseWind(token string) *weather.Wind { + match := windPattern.FindStringSubmatch(token) + if len(match) == 0 { + return nil + } + + wind := &weather.Wind{} + + // Parse direction + if match[1] == "VRB" { + wind.Variable = true + wind.Direction = 0 + } else { + dir, _ := strconv.Atoi(match[1]) + wind.Direction = dir + } + + // Parse speed + speed, _ := strconv.Atoi(match[2]) + wind.Speed = speed + + // Parse gust + if match[3] != "" { + gust, _ := strconv.Atoi(match[4]) + wind.Gust = gust + } + + // Parse unit + wind.Unit = match[5] + if wind.Unit == "" { + wind.Unit = "KT" // Default to knots + } + + return wind +} + +// parseVisibility parses visibility information +func parseVisibility(token string) *weather.Visibility { + // Try directional visibility first + if match := directionalVisibilityPattern.FindStringSubmatch(token); match != nil { + dist, _ := strconv.ParseFloat(match[1], 64) + return &weather.Visibility{ + Distance: dist, + Unit: "M", + Direction: match[2], + } + } + + // Try standard visibility pattern + match := visibilityPattern.FindStringSubmatch(token) + if len(match) == 0 { + return nil + } + + vis := &weather.Visibility{ + Modifier: match[1], + Unit: match[3], + } + + // Parse distance + distStr := match[2] + if strings.Contains(distStr, "/") { + // Fractional visibility (e.g., "1/4SM") + dist, err := ParseFraction(distStr) + if err == nil { + vis.Distance = dist + } else { + return nil + } + } else { + dist, err := strconv.ParseFloat(distStr, 64) + if err != nil { + return nil + } + vis.Distance = dist + } + + // Default unit + if vis.Unit == "" { + if vis.Distance >= 10 { + vis.Unit = "M" // Meters (e.g., 9999) + } else { + vis.Unit = "SM" // Statute miles + } + } + + return vis +} + diff --git a/internal/adapter/parser/weather/metar_parser_test.go b/internal/adapter/parser/weather/metar_parser_test.go new file mode 100644 index 0000000..74d0093 --- /dev/null +++ b/internal/adapter/parser/weather/metar_parser_test.go @@ -0,0 +1,127 @@ +package weather + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("METAR Parser", func() { + Describe("parseMetar", func() { + It("should parse a standard METAR", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar).ToNot(BeNil()) + Expect(metar.StationID).To(Equal("KJFK")) + Expect(metar.Wind).ToNot(BeNil()) + Expect(metar.Wind.Direction).To(Equal(350)) + Expect(metar.Wind.Speed).To(Equal(12)) + Expect(metar.Visibility).ToNot(BeNil()) + Expect(metar.Visibility.Distance).To(Equal(10.0)) + Expect(metar.Visibility.Unit).To(Equal("SM")) + Expect(len(metar.Clouds)).To(Equal(1)) + Expect(metar.Clouds[0].Type).To(Equal("FEW")) + Expect(metar.Temperature).ToNot(BeNil()) + Expect(metar.Temperature.Value).To(Equal(25.0)) + Expect(metar.Dewpoint).ToNot(BeNil()) + Expect(metar.Dewpoint.Value).To(Equal(18.0)) + Expect(metar.Altimeter).ToNot(BeNil()) + Expect(metar.Altimeter.Value).To(Equal(1013.0)) + }) + + It("should parse METAR with variable wind", func() { + raw := "METAR KORD 251200Z VRB05KT 10SM CLR 20/15 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Wind).ToNot(BeNil()) + Expect(metar.Wind.Variable).To(BeTrue()) + }) + + It("should parse METAR with gust", func() { + raw := "METAR KJFK 251200Z 27015G25KT 10SM FEW020 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Wind).ToNot(BeNil()) + Expect(metar.Wind.Gust).To(Equal(25)) + }) + + It("should parse METAR with AUTO modifier", func() { + raw := "METAR KJFK 251200Z AUTO 35012KT 10SM FEW020 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Modifier).To(Equal("AUTO")) + }) + + It("should parse METAR with COR modifier", func() { + raw := "METAR KJFK 251200Z COR 35012KT 10SM FEW020 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Modifier).To(Equal("COR")) + }) + + It("should parse METAR with weather phenomena", func() { + raw := "METAR KJFK 251200Z 35012KT 5SM -RA BKN030 OVC050 20/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(len(metar.Phenomena)).To(BeNumerically(">", 0)) + Expect(metar.Phenomena[0].Intensity).To(Equal("-")) + Expect(metar.Phenomena[0].Weather).To(Equal("RA")) + }) + + It("should parse METAR with multiple clouds", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 SCT030 BKN100 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(len(metar.Clouds)).To(Equal(3)) + }) + + It("should parse METAR with CB clouds", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM SCT030CB 25/18 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(len(metar.Clouds)).To(Equal(1)) + Expect(metar.Clouds[0].Modifier).To(Equal("CB")) + }) + + It("should parse METAR with altimeter in inHg", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 A2992=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Altimeter).ToNot(BeNil()) + Expect(metar.Altimeter.Unit).To(Equal("A")) + Expect(metar.Altimeter.Value).To(Equal(29.92)) + }) + + It("should parse METAR with negative temperature", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 M05/M10 Q1013=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Temperature).ToNot(BeNil()) + Expect(metar.Temperature.Value).To(Equal(-5.0)) + Expect(metar.Dewpoint).ToNot(BeNil()) + Expect(metar.Dewpoint.Value).To(Equal(-10.0)) + }) + + It("should parse METAR with remarks", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013 RMK TEST REMARKS=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(metar.Remarks).To(ContainSubstring("RMK")) + }) + + It("should handle unrecognized tokens as warnings", func() { + raw := "METAR KJFK 251200Z 35012KT 10SM FEW020 25/18 Q1013 UNKNOWN TOKEN=" + metar, err := parseMetar(raw, "METAR") + Expect(err).ToNot(HaveOccurred()) + Expect(len(metar.Warnings)).To(BeNumerically(">", 0)) + }) + + It("should parse SPECI reports", func() { + raw := "SPECI KORD 251215Z 27015G25KT 5SM -RA BKN030 OVC050 20/18 A2992=" + metar, err := parseMetar(raw, "SPECI") + Expect(err).ToNot(HaveOccurred()) + Expect(string(metar.ReportType)).To(Equal("SPECI")) + }) + }) +}) + diff --git a/internal/adapter/parser/weather/normalize.go b/internal/adapter/parser/weather/normalize.go new file mode 100644 index 0000000..288475b --- /dev/null +++ b/internal/adapter/parser/weather/normalize.go @@ -0,0 +1,157 @@ +package weather + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// ConvertKTToMPS converts knots to meters per second +func ConvertKTToMPS(knots int) int { + // 1 knot = 0.514444 m/s + return int(float64(knots) * 0.514444) +} + +// ConvertMPSToKT converts meters per second to knots +func ConvertMPSToKT(mps int) int { + // 1 m/s = 1.94384 knots + return int(float64(mps) * 1.94384) +} + +// ConvertSMToMeters converts statute miles to meters +func ConvertSMToMeters(sm float64) float64 { + // 1 SM = 1609.34 meters + return sm * 1609.34 +} + +// ConvertMetersToSM converts meters to statute miles +func ConvertMetersToSM(meters float64) float64 { + // 1 meter = 0.000621371 SM + return meters * 0.000621371 +} + +// ConvertInHgToHPa converts inches of mercury to hectopascals +func ConvertInHgToHPa(inHg float64) float64 { + // 1 inHg = 33.8639 hPa + return inHg * 33.8639 +} + +// ConvertHPatoInHg converts hectopascals to inches of mercury +func ConvertHPatoInHg(hPa float64) float64 { + // 1 hPa = 0.0295299 inHg + return hPa * 0.0295299 +} + +// ParseTime parses a time string in DDHHmmZ format to time.Time +// Uses the current year/month as reference +func ParseTime(timeStr string) (time.Time, error) { + match := timePattern.FindStringSubmatch(timeStr) + if len(match) == 0 { + return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr) + } + + day, err := strconv.Atoi(match[1]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid day: %w", err) + } + + hour, err := strconv.Atoi(match[2]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid hour: %w", err) + } + + min, err := strconv.Atoi(match[3]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid minute: %w", err) + } + + now := time.Now() + // Use current year and month, but adjust if day is in the future (likely next month) + t := time.Date(now.Year(), now.Month(), day, hour, min, 0, 0, time.UTC) + + // If the day is significantly in the past (more than 15 days), assume next month + if day < now.Day()-15 { + t = t.AddDate(0, 1, 0) + } + + return t, nil +} + +// ParseTAFValidity parses TAF validity period in DDHH/DDHH format +func ParseTAFValidity(validityStr string, issueTime time.Time) (time.Time, time.Time, error) { + match := tafValidityPattern.FindStringSubmatch(validityStr) + if len(match) == 0 { + return time.Time{}, time.Time{}, fmt.Errorf("invalid TAF validity format: %s", validityStr) + } + + fromDay, _ := strconv.Atoi(match[1]) + fromHour, _ := strconv.Atoi(match[2]) + toDay, _ := strconv.Atoi(match[3]) + toHour, _ := strconv.Atoi(match[4]) + + year := issueTime.Year() + month := issueTime.Month() + + fromTime := time.Date(year, month, fromDay, fromHour, 0, 0, 0, time.UTC) + toTime := time.Date(year, month, toDay, toHour, 0, 0, 0, time.UTC) + + // If toDay is less than fromDay, assume next month + if toDay < fromDay { + toTime = toTime.AddDate(0, 1, 0) + } + + return fromTime, toTime, nil +} + +// ParseFraction parses a fraction string like "1/4" or "1 1/2" +func ParseFraction(fracStr string) (float64, error) { + fracStr = strings.TrimSpace(fracStr) + + // Handle whole number with fraction: "1 1/2" + if strings.Contains(fracStr, " ") { + parts := strings.Fields(fracStr) + if len(parts) != 2 { + return 0, fmt.Errorf("invalid fraction format: %s", fracStr) + } + + whole, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0, fmt.Errorf("invalid whole number: %w", err) + } + + frac, err := parseSimpleFraction(parts[1]) + if err != nil { + return 0, err + } + + return whole + frac, nil + } + + // Handle simple fraction: "1/4" + return parseSimpleFraction(fracStr) +} + +func parseSimpleFraction(fracStr string) (float64, error) { + parts := strings.Split(fracStr, "/") + if len(parts) != 2 { + return 0, fmt.Errorf("invalid fraction format: %s", fracStr) + } + + numerator, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0, fmt.Errorf("invalid numerator: %w", err) + } + + denominator, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + return 0, fmt.Errorf("invalid denominator: %w", err) + } + + if denominator == 0 { + return 0, fmt.Errorf("division by zero") + } + + return numerator / denominator, nil +} + diff --git a/internal/adapter/parser/weather/patterns.go b/internal/adapter/parser/weather/patterns.go new file mode 100644 index 0000000..b78e10d --- /dev/null +++ b/internal/adapter/parser/weather/patterns.go @@ -0,0 +1,50 @@ +package weather + +import "regexp" + +var ( + // Wind patterns: 35012KT, VRB05KT, 27015G25KT, 00000KT + windPattern = regexp.MustCompile(`^(?P