diff --git a/internal/adapters/outbound/postgres/conditions_codes.go b/internal/adapters/outbound/postgres/conditions_codes.go new file mode 100644 index 0000000..ce31e01 --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_codes.go @@ -0,0 +1,107 @@ +// conditions_codes.go selects current-conditions WMO codes from source candidates. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +import "gitea.maximumdirect.net/ejr/weatherfeeder/model" + +type currentConditionsConditionCodeCandidate struct { + EventSource string + ConditionCode model.WMOCode +} + +type conditionCodeFamily int + +const ( + conditionCodeFamilyUnknown conditionCodeFamily = iota + conditionCodeFamilyClearOrCloud + conditionCodeFamilyFog + conditionCodeFamilyDrizzle + conditionCodeFamilyRain + conditionCodeFamilySnow + conditionCodeFamilyThunderstorm +) + +var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{ + conditionCodeFamilyClearOrCloud: {0, 1, 2, 3}, + conditionCodeFamilyFog: {45, 48}, + conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57}, + conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67}, + conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77}, + conditionCodeFamilyThunderstorm: {95, 96, 99}, +} + +var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies() + +func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily { + families := make(map[model.WMOCode]conditionCodeFamily) + for family, ranking := range currentConditionsConditionCodeRankings { + for _, code := range ranking { + families[code] = family + } + } + return families +} + +func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode { + seenSources := make(map[string]struct{}) + familyCounts := make(map[conditionCodeFamily]int) + codeCounts := make(map[model.WMOCode]int) + + for _, candidate := range candidates { + if _, seen := seenSources[candidate.EventSource]; seen { + continue + } + seenSources[candidate.EventSource] = struct{}{} + + family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode] + if !ok { + continue + } + familyCounts[family]++ + codeCounts[candidate.ConditionCode]++ + } + + winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts) + if !ok { + return model.WMOUnknown + } + + return currentConditionsWinningConditionCode(winningFamily, codeCounts) +} + +func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) { + winningFamily := conditionCodeFamilyUnknown + winningCount := 0 + tied := false + + for family, count := range counts { + if count > winningCount { + winningFamily = family + winningCount = count + tied = false + continue + } + if count == winningCount { + tied = true + } + } + + if winningCount == 0 || tied { + return conditionCodeFamilyUnknown, false + } + return winningFamily, true +} + +func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode { + winningCode := model.WMOUnknown + winningCount := 0 + + for _, code := range currentConditionsConditionCodeRankings[family] { + if counts[code] > winningCount { + winningCode = code + winningCount = counts[code] + } + } + + return winningCode +} diff --git a/internal/adapters/outbound/postgres/conditions_codes_test.go b/internal/adapters/outbound/postgres/conditions_codes_test.go new file mode 100644 index 0000000..f008a02 --- /dev/null +++ b/internal/adapters/outbound/postgres/conditions_codes_test.go @@ -0,0 +1,116 @@ +// conditions_codes_test.go tests current-conditions WMO code selection. +// Layer: adapters/outbound/postgres conditions feature. +package postgres + +import ( + "testing" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func TestSelectCurrentConditionsConditionCode(t *testing.T) { + tests := []struct { + name string + candidates []currentConditionsConditionCodeCandidate + want model.WMOCode + }{ + { + name: "clear cloud ranking breaks exact tie", + candidates: conditionCodeCandidates( + 0, + 1, + 2, + ), + want: 0, + }, + { + name: "clear cloud family wins over thunderstorm", + candidates: conditionCodeCandidates( + 1, + 2, + 95, + ), + want: 1, + }, + { + name: "tied families return unknown", + candidates: conditionCodeCandidates( + 0, + 95, + ), + want: model.WMOUnknown, + }, + { + name: "rain ranking breaks exact tie", + candidates: conditionCodeCandidates( + 61, + 63, + 80, + ), + want: 61, + }, + { + name: "three tied families return unknown", + candidates: conditionCodeCandidates( + 61, + 95, + 0, + ), + want: model.WMOUnknown, + }, + { + name: "single thunderstorm code wins", + candidates: conditionCodeCandidates( + 95, + ), + want: 95, + }, + { + name: "unrecognized only returns unknown", + candidates: conditionCodeCandidates( + 4, + 100, + ), + want: model.WMOUnknown, + }, + { + name: "duplicate source only votes once", + candidates: []currentConditionsConditionCodeCandidate{ + {EventSource: "source-1", ConditionCode: 95}, + {EventSource: "source-1", ConditionCode: 95}, + {EventSource: "source-2", ConditionCode: 0}, + {EventSource: "source-3", ConditionCode: 0}, + }, + want: 0, + }, + { + name: "exact code frequency wins before ranking", + candidates: []currentConditionsConditionCodeCandidate{ + {EventSource: "source-1", ConditionCode: 61}, + {EventSource: "source-2", ConditionCode: 63}, + {EventSource: "source-3", ConditionCode: 63}, + }, + want: 63, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectCurrentConditionsConditionCode(tt.candidates) + if got != tt.want { + t.Fatalf("expected condition code %d, got %d", tt.want, got) + } + }) + } +} + +func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate { + candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes)) + for i, code := range codes { + candidates = append(candidates, currentConditionsConditionCodeCandidate{ + EventSource: string(rune('a' + i)), + ConditionCode: code, + }) + } + return candidates +}