From d0fd461e38de767ebf6f0df4567f0319026fc08e Mon Sep 17 00:00:00 2001 From: windyboy Date: Wed, 24 Dec 2025 16:38:03 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20weather=20report=20parsing=20?= =?UTF-8?q?for=20METAR,=20SPECI,=20and=20TAF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive weather parsing capabilities following Clean Architecture principles with composite parser pattern for routing between aviation and weather messages. ## Features Added - Weather report parsing (METAR, SPECI, TAF) - Composite parser pattern for message routing - Lenient parsing with warnings for unrecognized tokens - Support for PROB and RMK sections in TAF - Rich domain modeling with typed weather elements ## Architecture **Domain Layer** (internal/domain/weather/): - WeatherMessage interface with Metar and Taf implementations - Weather elements: Wind, Visibility, Cloud, Temperature, Altimeter, Phenomenon - Domain errors: ErrInvalidFormat, ErrMissingStation, ErrMissingTime **Port Layer** (internal/port/weather_parser.go): - WeatherParser interface with CanParse and Parse methods **Adapter Layer** (internal/adapter/parser/weather/): - WeatherParserImpl with classification and parsing logic - Comprehensive regex patterns for weather elements - METAR/SPECI parser with element extraction - TAF parser with period handling (FM, TEMPO, BECMG, PROB) - Helper functions for time parsing and unit conversions **Composite Parser** (internal/adapter/parser/composite.go): - Routes weather reports to weather parser - Falls back to aviation parser for telegrams - Converts WeatherMessage to ParsedTelegram format ## Integration - Updated ProvideParser to create composite parser with weather parser - Added weather parser to Wire DI configuration - Updated processor_bench_test.go for weather parser integration - Documentation added in docs/weather-parser.md ## Testing - 29 comprehensive tests for weather parsing (all passing) - Tests for classification, METAR, SPECI, TAF, and composite routing - Benchmark compatibility maintained ## Fixes Applied - TAF PROB parsing: Include PROB/RMK in special section detection - Composite test: Updated to use properly formatted AFTN telegram - Linter issues: Switch statement refactor, removed unused patterns - Ineffective break statement fixed in TAF parser ## Coverage ~1,743 lines of new code with: - Complete METAR/SPECI parsing - TAF parsing with period support - Lenient error handling with warnings - Unit conversions and time utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 1 + docs/dev-guide.md | 2 +- docs/weather-parser.md | 121 ++++++ internal/adapter/parser/composite.go | 69 ++++ internal/adapter/parser/composite_test.go | 80 ++++ internal/adapter/parser/provider.go | 12 +- internal/adapter/parser/weather/classifier.go | 44 ++ .../adapter/parser/weather/classifier_test.go | 53 +++ internal/adapter/parser/weather/lexer.go | 105 +++++ .../adapter/parser/weather/metar_parser.go | 305 ++++++++++++++ .../parser/weather/metar_parser_test.go | 127 ++++++ internal/adapter/parser/weather/normalize.go | 157 +++++++ internal/adapter/parser/weather/patterns.go | 50 +++ internal/adapter/parser/weather/provider.go | 49 +++ internal/adapter/parser/weather/suite_test.go | 14 + internal/adapter/parser/weather/taf_parser.go | 386 ++++++++++++++++++ .../adapter/parser/weather/taf_parser_test.go | 98 +++++ internal/app/processor_bench_test.go | 7 +- internal/domain/weather/elements.go | 60 +++ internal/domain/weather/errors.go | 18 + internal/domain/weather/types.go | 98 +++++ internal/port/weather_parser.go | 13 + pkg/di/wire.go | 6 +- pkg/di/wire_gen.go | 9 +- 24 files changed, 1873 insertions(+), 11 deletions(-) create mode 100644 docs/weather-parser.md create mode 100644 internal/adapter/parser/composite.go create mode 100644 internal/adapter/parser/composite_test.go create mode 100644 internal/adapter/parser/weather/classifier.go create mode 100644 internal/adapter/parser/weather/classifier_test.go create mode 100644 internal/adapter/parser/weather/lexer.go create mode 100644 internal/adapter/parser/weather/metar_parser.go create mode 100644 internal/adapter/parser/weather/metar_parser_test.go create mode 100644 internal/adapter/parser/weather/normalize.go create mode 100644 internal/adapter/parser/weather/patterns.go create mode 100644 internal/adapter/parser/weather/provider.go create mode 100644 internal/adapter/parser/weather/suite_test.go create mode 100644 internal/adapter/parser/weather/taf_parser.go create mode 100644 internal/adapter/parser/weather/taf_parser_test.go create mode 100644 internal/domain/weather/elements.go create mode 100644 internal/domain/weather/errors.go create mode 100644 internal/domain/weather/types.go create mode 100644 internal/port/weather_parser.go 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\d{3}|VRB)(?P\d{2,3})(G(?P\d{2,3}))?(?PKT|MPS)$`) + + // Variable wind: 180V240 (variable from 180 to 240 degrees) + variableWindPattern = regexp.MustCompile(`^(?P\d{3})V(?P\d{3})$`) + + // Visibility patterns: 9999, 10SM, M1/4SM, 1 1/2SM, 1500 + visibilityPattern = regexp.MustCompile(`^(?P[MP\+\-]?)(?P\d+(?:\s*\d+/\d+)?)(?PSM|M)?$`) + + // Directional visibility: 2000NE (visibility in a specific direction) + directionalVisibilityPattern = regexp.MustCompile(`^(?P\d{4})(?P[NSEW]{1,2})$`) + + // Cloud patterns: FEW020, SCT030CB, BKN100, OVC200, VV010, SKC, CLR, NSC + cloudPattern = regexp.MustCompile(`^(?PFEW|SCT|BKN|OVC|VV)(?P\d{3})(?PCB|TCU)?$`) + + // Special cloud codes: SKC (sky clear), CLR (clear), NSC (no significant clouds) + skyClearPattern = regexp.MustCompile(`^(SKC|CLR|NSC)$`) + + // Temperature/Dewpoint: 25/18, M05/M10, XX/XX + tempPattern = regexp.MustCompile(`^(?PM?)(?P\d{2})/(?PM?)(?P\d{2})$`) + + // Altimeter patterns: Q1013 (hPa), A2992 (inHg) + altimeterPattern = regexp.MustCompile(`^(?P[QA])(?P\d{4})$`) + + // Weather phenomenon patterns: -RA, +SN, TSRA, FZFG, BR, FG, etc. + // Intensity: -, +, or empty + // Descriptors: MI, BC, PR, DR, BL, SH, TS, FZ, DZ, RA, SN, SG, IC, PL, GR, GS, UP, BR, FG, FU, VA, DU, SA, HZ, PY, PO, SQ, FC, SS, DS + // Weather: DZ, RA, SN, SG, IC, PL, GR, GS, UP, BR, FG, FU, VA, DU, SA, HZ, PY, PO, SQ, FC, SS, DS + phenomenonPattern = regexp.MustCompile(`^(?P[\+\-])?(?PMI|BC|PR|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)?(?PDZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+$`) + + // Time pattern: 251200Z (DDHHmmZ format) + timePattern = regexp.MustCompile(`^(?P\d{2})(?P\d{2})(?P\d{2})Z$`) + + // TAF validity period: 2512/2612 (DDHH/DDHH format) + tafValidityPattern = regexp.MustCompile(`^(?P\d{2})(?P\d{2})/(?P\d{2})(?P\d{2})$`) + + // TAF period markers: FM251200, TEMPO2512/2515, BECMG2512/2515 + tafFMPattern = regexp.MustCompile(`^FM(?P\d{2})(?P\d{2})(?P\d{2})$`) + tafTEMPOPattern = regexp.MustCompile(`^TEMPO(?P\d{2})(?P\d{2})/(?P\d{2})(?P\d{2})$`) + tafBECMGPattern = regexp.MustCompile(`^BECMG(?P\d{2})(?P\d{2})/(?P\d{2})(?P\d{2})$`) + + // Modifiers: AUTO, COR, NIL, etc. + modifierPattern = regexp.MustCompile(`^(AUTO|COR|NIL)$`) +) + diff --git a/internal/adapter/parser/weather/provider.go b/internal/adapter/parser/weather/provider.go new file mode 100644 index 0000000..2181559 --- /dev/null +++ b/internal/adapter/parser/weather/provider.go @@ -0,0 +1,49 @@ +package weather + +import ( + "caatsm/internal/domain/weather" + "caatsm/internal/port" + "fmt" +) + +// WeatherParserImpl implements the WeatherParser interface +type WeatherParserImpl struct{} + +// NewWeatherParser creates a new weather parser instance +func NewWeatherParser() port.WeatherParser { + return &WeatherParserImpl{} +} + +// CanParse determines if the raw string can be parsed as a weather report +func (p *WeatherParserImpl) CanParse(raw string) bool { + reportType, ok := Classify(raw) + if !ok { + return false + } + + // Check for valid ending + if !HasValidEnding(raw) { + return false + } + + _ = reportType // Suppress unused variable warning + return true +} + +// Parse parses a raw weather report string and returns a WeatherMessage +func (p *WeatherParserImpl) Parse(raw string) (weather.WeatherMessage, error) { + reportType, ok := Classify(raw) + if !ok { + return nil, weather.ErrInvalidFormat + } + + switch reportType { + case "METAR", "SPECI": + return parseMetar(raw, reportType) + case "TAF": + return parseTaf(raw) + default: + return nil, fmt.Errorf("unsupported report type: %s", reportType) + } +} + diff --git a/internal/adapter/parser/weather/suite_test.go b/internal/adapter/parser/weather/suite_test.go new file mode 100644 index 0000000..fce6a48 --- /dev/null +++ b/internal/adapter/parser/weather/suite_test.go @@ -0,0 +1,14 @@ +package weather + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWeather(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Weather Parser Suite") +} + diff --git a/internal/adapter/parser/weather/taf_parser.go b/internal/adapter/parser/weather/taf_parser.go new file mode 100644 index 0000000..bca8cf3 --- /dev/null +++ b/internal/adapter/parser/weather/taf_parser.go @@ -0,0 +1,386 @@ +package weather + +import ( + "caatsm/internal/domain/weather" + "fmt" + "strconv" + "strings" + "time" +) + +// parseTaf parses a TAF report +func parseTaf(raw string) (*weather.Taf, error) { + tokens := Tokenize(raw) + if len(tokens) < 4 { + 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]) + if err != nil { + return nil, fmt.Errorf("failed to parse issue time: %w", 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, + } + + // Find first special section + firstSpecialIdx := len(tokens) + for i := pos; i < len(tokens); i++ { + upperToken := strings.ToUpper(tokens[i]) + if strings.HasPrefix(upperToken, "FM") || + strings.HasPrefix(upperToken, "TEMPO") || + strings.HasPrefix(upperToken, "BECMG") || + strings.HasPrefix(upperToken, "PROB") || + strings.HasPrefix(upperToken, "RMK") { + firstSpecialIdx = i + break + } + } + + // Parse main period tokens + if firstSpecialIdx > pos { + mainTokens := tokens[pos:firstSpecialIdx] + parsePeriodElements(mainTokens, &mainPeriod, taf) + taf.Periods = append(taf.Periods, mainPeriod) + pos = firstSpecialIdx + } + + // Parse special sections (FM, TEMPO, BECMG) + var pendingProb int + for pos < len(tokens) { + upperToken := strings.ToUpper(tokens[pos]) + + switch { + case strings.HasPrefix(upperToken, "PROB"): + // PROB30 or PROB40 - probability for next period + probStr := strings.TrimPrefix(upperToken, "PROB") + pendingProb, _ = strconv.Atoi(probStr) + pos++ + + case strings.HasPrefix(upperToken, "FM"): + period, newPos, err := parseFMPeriod(tokens, pos, taf.IssueTimeVal) + if err != nil { + taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse FM period: %v", err)) + pos++ + continue + } + if pendingProb > 0 { + period.Probability = pendingProb + pendingProb = 0 + } + taf.Periods = append(taf.Periods, period) + pos = newPos + + case strings.HasPrefix(upperToken, "TEMPO"): + period, newPos, err := parseTEMPOPeriod(tokens, pos, taf.IssueTimeVal) + if err != nil { + taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse TEMPO period: %v", err)) + pos++ + continue + } + if pendingProb > 0 { + period.Probability = pendingProb + pendingProb = 0 + } + taf.Periods = append(taf.Periods, period) + pos = newPos + + case strings.HasPrefix(upperToken, "BECMG"): + period, newPos, err := parseBECMGPeriod(tokens, pos, taf.IssueTimeVal) + if err != nil { + taf.Warnings = append(taf.Warnings, fmt.Sprintf("failed to parse BECMG period: %v", err)) + pos++ + continue + } + if pendingProb > 0 { + period.Probability = pendingProb + pendingProb = 0 + } + taf.Periods = append(taf.Periods, period) + pos = newPos + + case strings.HasPrefix(upperToken, "RMK"): + // Remarks section - include RMK token and all following tokens + taf.Remarks = strings.Join(tokens[pos:], " ") + // Set pos to exit the loop + pos = len(tokens) + + default: + // Unrecognized token + taf.Warnings = append(taf.Warnings, fmt.Sprintf("unrecognized token: %s", tokens[pos])) + pos++ + } + } + + return taf, nil +} + +// parseFMPeriod parses an FM (from) period +func parseFMPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { + period := weather.TafPeriod{ + Type: "FM", + } + + match := tafFMPattern.FindStringSubmatch(tokens[startPos]) + if len(match) == 0 { + return period, startPos + 1, fmt.Errorf("invalid FM format") + } + + day, _ := strconv.Atoi(match[1]) + 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) + + // Find end of this period (next FM, TEMPO, BECMG, or end) + endPos := len(tokens) + for i := startPos + 1; i < len(tokens); i++ { + upperToken := strings.ToUpper(tokens[i]) + if strings.HasPrefix(upperToken, "FM") || + strings.HasPrefix(upperToken, "TEMPO") || + strings.HasPrefix(upperToken, "BECMG") || + strings.HasPrefix(upperToken, "RMK") { + endPos = i + break + } + } + + // Parse period elements + periodTokens := tokens[startPos+1 : endPos] + parsePeriodElements(periodTokens, &period, nil) + + // Set valid_to to start of next period or end of validity + if endPos < len(tokens) { + upperToken := strings.ToUpper(tokens[endPos]) + if strings.HasPrefix(upperToken, "FM") { + // Next period starts here + if match := tafFMPattern.FindStringSubmatch(tokens[endPos]); match != nil { + 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) + } + } + } + + return period, endPos, nil +} + +// parseTEMPOPeriod parses a TEMPO (temporary) period +func parseTEMPOPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { + period := weather.TafPeriod{ + Type: "TEMPO", + } + + match := tafTEMPOPattern.FindStringSubmatch(tokens[startPos]) + if len(match) == 0 { + return period, startPos + 1, fmt.Errorf("invalid TEMPO format") + } + + fromDay, _ := strconv.Atoi(match[1]) + fromHour, _ := strconv.Atoi(match[2]) + 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) + } + + // Find end of this period + endPos := startPos + 1 + for endPos < len(tokens) { + upperToken := strings.ToUpper(tokens[endPos]) + if strings.HasPrefix(upperToken, "FM") || + strings.HasPrefix(upperToken, "TEMPO") || + strings.HasPrefix(upperToken, "BECMG") || + strings.HasPrefix(upperToken, "RMK") { + break + } + endPos++ + } + + // Parse period elements + periodTokens := tokens[startPos+1 : endPos] + parsePeriodElements(periodTokens, &period, nil) + + return period, endPos, nil +} + +// parseBECMGPeriod parses a BECMG (becoming) period +func parseBECMGPeriod(tokens []string, startPos int, issueTimeVal time.Time) (weather.TafPeriod, int, error) { + period := weather.TafPeriod{ + Type: "BECMG", + } + + match := tafBECMGPattern.FindStringSubmatch(tokens[startPos]) + if len(match) == 0 { + return period, startPos + 1, fmt.Errorf("invalid BECMG format") + } + + fromDay, _ := strconv.Atoi(match[1]) + fromHour, _ := strconv.Atoi(match[2]) + 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) + } + + // Find end of this period + endPos := startPos + 1 + for endPos < len(tokens) { + upperToken := strings.ToUpper(tokens[endPos]) + if strings.HasPrefix(upperToken, "FM") || + strings.HasPrefix(upperToken, "TEMPO") || + strings.HasPrefix(upperToken, "BECMG") || + strings.HasPrefix(upperToken, "RMK") { + break + } + endPos++ + } + + // Parse period elements + periodTokens := tokens[startPos+1 : endPos] + parsePeriodElements(periodTokens, &period, nil) + + 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 + + // Parse wind + if pos < len(tokens) { + if wind := parseWind(tokens[pos]); wind != nil { + period.Wind = wind + pos++ + + // 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++ + } + } + } + } + + // 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++ + } + } +} + diff --git a/internal/adapter/parser/weather/taf_parser_test.go b/internal/adapter/parser/weather/taf_parser_test.go new file mode 100644 index 0000000..24ac189 --- /dev/null +++ b/internal/adapter/parser/weather/taf_parser_test.go @@ -0,0 +1,98 @@ +package weather + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TAF Parser", func() { + Describe("parseTaf", func() { + It("should parse a simple TAF", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(taf).ToNot(BeNil()) + Expect(taf.StationID).To(Equal("KJFK")) + Expect(len(taf.Periods)).To(BeNumerically(">", 0)) + }) + + It("should parse TAF with FM period", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 FM251800 36015KT 10SM SCT030=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(len(taf.Periods)).To(BeNumerically(">=", 2)) + Expect(taf.Periods[1].Type).To(Equal("FM")) + }) + + 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) + Expect(err).ToNot(HaveOccurred()) + Expect(len(taf.Periods)).To(BeNumerically(">=", 1)) + // Find TEMPO period + found := false + for _, period := range taf.Periods { + if period.Type == "TEMPO" { + found = true + Expect(period.Wind).ToNot(BeNil()) + Expect(len(period.Phenomena)).To(BeNumerically(">", 0)) + break + } + } + Expect(found).To(BeTrue()) + }) + + It("should parse TAF with BECMG period", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 BECMG2512/2515 36015KT=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + // Find BECMG period + found := false + for _, period := range taf.Periods { + if period.Type == "BECMG" { + found = true + break + } + } + Expect(found).To(BeTrue()) + }) + + 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) + Expect(err).ToNot(HaveOccurred()) + // Find period with probability + found := false + for _, period := range taf.Periods { + if period.Probability > 0 { + found = true + Expect(period.Probability).To(Equal(30)) + break + } + } + Expect(found).To(BeTrue()) + }) + + It("should parse TAF with multiple periods", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 FM251800 36015KT 10SM SCT030 TEMPO2520/2602 27015G25KT 5SM -RA=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(len(taf.Periods)).To(BeNumerically(">=", 2)) + }) + + It("should parse TAF with remarks", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 RMK TEST REMARKS=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(taf.Remarks).To(ContainSubstring("RMK")) + }) + + It("should handle unrecognized tokens as warnings", func() { + raw := "TAF KJFK 251200Z 2512/2612 35012KT 10SM FEW020 UNKNOWN TOKEN=" + taf, err := parseTaf(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(len(taf.Warnings)).To(BeNumerically(">", 0)) + }) + }) +}) + diff --git a/internal/app/processor_bench_test.go b/internal/app/processor_bench_test.go index b5e73dd..b527009 100644 --- a/internal/app/processor_bench_test.go +++ b/internal/app/processor_bench_test.go @@ -3,6 +3,7 @@ package app import ( "caatsm/internal/adapter/dto" "caatsm/internal/adapter/parser" + "caatsm/internal/adapter/parser/weather" "caatsm/internal/infra/config" "caatsm/internal/infra/telemetry" "context" @@ -61,7 +62,8 @@ NNNN`) // createBenchmarkProcessor creates a processor with mocks for benchmarking func createBenchmarkProcessor() *MessageProcessor { - aviationParser := parser.ProvideParser() + weatherParser := weather.NewWeatherParser() + aviationParser := parser.ProvideParser(weatherParser) mockRepo := &mockRepository{} mockPub := &mockPublisher{} logger := zap.NewNop() @@ -135,7 +137,8 @@ func BenchmarkHandleMixed(b *testing.B) { // BenchmarkHandleParseOnly benchmarks parsing without persistence/publishing // This isolates parser performance func BenchmarkHandleParseOnly(b *testing.B) { - aviationParser := parser.ProvideParser() + weatherParser := weather.NewWeatherParser() + aviationParser := parser.ProvideParser(weatherParser) // Use a repository that does nothing mockRepo := &mockRepository{} // Use a publisher that does nothing diff --git a/internal/domain/weather/elements.go b/internal/domain/weather/elements.go new file mode 100644 index 0000000..be9b600 --- /dev/null +++ b/internal/domain/weather/elements.go @@ -0,0 +1,60 @@ +package weather + +import "time" + +// Wind represents wind information +type Wind struct { + Direction int `json:"direction"` // Degrees + Speed int `json:"speed"` // KT or MPS + Gust int `json:"gust,omitempty"` // Gust speed + Variable bool `json:"variable,omitempty"` // VRB + VariableFrom int `json:"variable_from,omitempty"` // Variable wind from direction + VariableTo int `json:"variable_to,omitempty"` // Variable wind to direction + Unit string `json:"unit"` // "KT", "MPS" +} + +// Visibility represents visibility information +type Visibility struct { + Distance float64 `json:"distance"` // Meters or statute miles + Unit string `json:"unit"` // "M", "SM" + Direction string `json:"direction,omitempty"` // Directional visibility + Modifier string `json:"modifier,omitempty"` // +, -, M, P +} + +// Cloud represents cloud information +type Cloud struct { + Type string `json:"type"` // FEW, SCT, BKN, OVC, VV + Altitude int `json:"altitude"` // Feet + Modifier string `json:"modifier,omitempty"` // CB, TCU +} + +// Temperature represents temperature or dewpoint +type Temperature struct { + Value float64 `json:"value"` + Unit string `json:"unit"` // "C" +} + +// Altimeter represents altimeter setting +type Altimeter struct { + Value float64 `json:"value"` + Unit string `json:"unit"` // "QNH" (hPa), "A" (inHg) +} + +// Phenomenon represents weather phenomenon +type Phenomenon struct { + Intensity string `json:"intensity,omitempty"` // -, + + Descriptor string `json:"descriptor,omitempty"` // MI, BC, PR, TS, etc. + Weather string `json:"weather"` // RA, SN, FG, etc. +} + +// TafPeriod represents a TAF period (FM, TEMPO, BECMG, or main forecast) +type TafPeriod struct { + Type string `json:"type"` // "FM", "TEMPO", "BECMG", "MAIN" + ValidFrom time.Time `json:"valid_from,omitempty"` + ValidTo time.Time `json:"valid_to,omitempty"` + Wind *Wind `json:"wind,omitempty"` + Visibility *Visibility `json:"visibility,omitempty"` + Clouds []Cloud `json:"clouds,omitempty"` + Phenomena []Phenomenon `json:"phenomena,omitempty"` + Probability int `json:"probability,omitempty"` // PROB30, PROB40 +} diff --git a/internal/domain/weather/errors.go b/internal/domain/weather/errors.go new file mode 100644 index 0000000..2c56236 --- /dev/null +++ b/internal/domain/weather/errors.go @@ -0,0 +1,18 @@ +package weather + +import "errors" + +var ( + // ErrInvalidFormat indicates an invalid weather report format + ErrInvalidFormat = errors.New("invalid weather report format") + + // ErrUnsupportedToken indicates an unsupported token in the report + ErrUnsupportedToken = errors.New("unsupported token") + + // ErrMissingStation indicates missing station identifier + ErrMissingStation = errors.New("missing station identifier") + + // ErrMissingTime indicates missing time information + ErrMissingTime = errors.New("missing time information") +) + diff --git a/internal/domain/weather/types.go b/internal/domain/weather/types.go new file mode 100644 index 0000000..be34454 --- /dev/null +++ b/internal/domain/weather/types.go @@ -0,0 +1,98 @@ +package weather + +import "time" + +// ReportType represents the type of weather report +type ReportType string + +const ( + ReportTypeMETAR ReportType = "METAR" + ReportTypeSPECI ReportType = "SPECI" + ReportTypeTAF ReportType = "TAF" +) + +// WeatherMessage is the common interface for all weather messages +type WeatherMessage interface { + Type() ReportType + Station() string + IssueTime() time.Time + RawText() string +} + +// Metar represents a METAR or SPECI weather report +type Metar struct { + ReportType ReportType `json:"type"` + StationID string `json:"station"` + IssueTimeVal time.Time `json:"issue_time"` + ObsTime time.Time `json:"obs_time,omitempty"` + RawTextVal string `json:"raw_text"` + + // Core elements + Wind *Wind `json:"wind,omitempty"` + Visibility *Visibility `json:"visibility,omitempty"` + Clouds []Cloud `json:"clouds,omitempty"` + Temperature *Temperature `json:"temperature,omitempty"` + Dewpoint *Temperature `json:"dewpoint,omitempty"` + Altimeter *Altimeter `json:"altimeter,omitempty"` + Phenomena []Phenomenon `json:"phenomena,omitempty"` + + // Optional fields + Modifier string `json:"modifier,omitempty"` // AUTO, COR + Remarks string `json:"remarks,omitempty"` + Warnings []string `json:"warnings,omitempty"` // Unrecognized tokens +} + +// Type returns the report type +func (m *Metar) Type() ReportType { + return m.ReportType +} + +// Station returns the station identifier +func (m *Metar) Station() string { + return m.StationID +} + +// IssueTime returns the issue time +func (m *Metar) IssueTime() time.Time { + return m.IssueTimeVal +} + +// RawText returns the raw text +func (m *Metar) RawText() string { + return m.RawTextVal +} + +// Taf represents a TAF (Terminal Aerodrome Forecast) weather report +type Taf struct { + ReportType ReportType `json:"type"` + StationID string `json:"station"` + IssueTimeVal time.Time `json:"issue_time"` + ValidFrom time.Time `json:"valid_from"` + ValidTo time.Time `json:"valid_to"` + RawTextVal string `json:"raw_text"` + + Periods []TafPeriod `json:"periods"` // FM, TEMPO, BECMG segments + Remarks string `json:"remarks,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// Type returns the report type +func (t *Taf) Type() ReportType { + return t.ReportType +} + +// Station returns the station identifier +func (t *Taf) Station() string { + return t.StationID +} + +// IssueTime returns the issue time +func (t *Taf) IssueTime() time.Time { + return t.IssueTimeVal +} + +// RawText returns the raw text +func (t *Taf) RawText() string { + return t.RawTextVal +} + diff --git a/internal/port/weather_parser.go b/internal/port/weather_parser.go new file mode 100644 index 0000000..a7fa53e --- /dev/null +++ b/internal/port/weather_parser.go @@ -0,0 +1,13 @@ +package port + +import "caatsm/internal/domain/weather" + +// WeatherParser defines the interface for parsing weather reports +type WeatherParser interface { + // CanParse determines if the raw string can be parsed as a weather report + CanParse(raw string) bool + + // Parse parses a raw weather report string and returns a WeatherMessage + Parse(raw string) (weather.WeatherMessage, error) +} + diff --git a/pkg/di/wire.go b/pkg/di/wire.go index 6f42bbf..05bedd1 100644 --- a/pkg/di/wire.go +++ b/pkg/di/wire.go @@ -4,6 +4,7 @@ package di import ( "caatsm/internal/adapter/parser" + weatherparser "caatsm/internal/adapter/parser/weather" "caatsm/internal/app" "caatsm/internal/infra/config" "caatsm/internal/infra/log" @@ -46,7 +47,10 @@ var runtimeSet = wire.NewSet( nats.ProvideJetStream, nats.ProvidePublisher, - // Parser + // Weather Parser + weatherparser.NewWeatherParser, + + // Parser (composite, depends on weather parser) parser.ProvideParser, // Telemetry diff --git a/pkg/di/wire_gen.go b/pkg/di/wire_gen.go index 64d408e..31b809f 100644 --- a/pkg/di/wire_gen.go +++ b/pkg/di/wire_gen.go @@ -8,6 +8,7 @@ package di import ( "caatsm/internal/adapter/parser" + "caatsm/internal/adapter/parser/weather" "caatsm/internal/app" "caatsm/internal/infra/config" "caatsm/internal/infra/log" @@ -21,7 +22,8 @@ import ( // Injectors from wire.go: func buildAppComponents() (*appComponents, error) { - parserParser := parser.ProvideParser() + weatherParser := weather.NewWeatherParser() + parserParser := parser.ProvideParser(weatherParser) configConfig, err := config.ProvideConfig() if err != nil { return nil, err @@ -69,7 +71,8 @@ func buildAppComponents() (*appComponents, error) { } func buildAppComponentsWithConfig(cfg *config.Config) (*appComponents, error) { - parserParser := parser.ProvideParser() + weatherParser := weather.NewWeatherParser() + parserParser := parser.ProvideParser(weatherParser) logger, err := log.ProvideLogger(cfg) if err != nil { return nil, err @@ -132,7 +135,7 @@ func InitializeAppWithConfig(cfg *config.Config) (*app.MessageProcessor, *nats.C return comps.Processor, comps.Consumer, comps.Monitoring, nil } -var runtimeSet = wire.NewSet(log.ProvideLogger, postgres.ProvideDB, postgres.ProvideRepository, nats.ProvideNATSConn, nats.ProvideJetStream, nats.ProvidePublisher, parser.ProvideParser, telemetry.ProvideRecorder, app.NewMessageProcessor, nats.ProvideConsumer, monitoring.ProvideServer) +var runtimeSet = wire.NewSet(log.ProvideLogger, postgres.ProvideDB, postgres.ProvideRepository, nats.ProvideNATSConn, nats.ProvideJetStream, nats.ProvidePublisher, weather.NewWeatherParser, parser.ProvideParser, telemetry.ProvideRecorder, app.NewMessageProcessor, nats.ProvideConsumer, monitoring.ProvideServer) type appComponents struct { Processor *app.MessageProcessor