feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
710 lines
18 KiB
Go
710 lines
18 KiB
Go
package nws
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.maximumdirect.net/ejr/feedkit/config"
|
||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
|
||
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
|
||
)
|
||
|
||
// ObservationSource polls an NWS station observation endpoint and emits a single Observation Event.
|
||
//
|
||
// This corresponds to URLs like:
|
||
//
|
||
// https://api.weather.gov/stations/KSTL/observations/latest
|
||
type ObservationSource struct {
|
||
name string
|
||
url string
|
||
userAgent string
|
||
client *http.Client
|
||
}
|
||
|
||
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
|
||
if strings.TrimSpace(cfg.Name) == "" {
|
||
return nil, fmt.Errorf("nws_observation: name is required")
|
||
}
|
||
if cfg.Params == nil {
|
||
return nil, fmt.Errorf("nws_observation %q: params are required (need params.url and params.user_agent)", cfg.Name)
|
||
}
|
||
|
||
// feedkit keeps config domain-agnostic by storing driver-specific settings in Params.
|
||
// Use ParamString so we don't have to type-assert cfg.Params["url"] everywhere.
|
||
url, ok := cfg.ParamString("url", "URL")
|
||
if !ok {
|
||
return nil, fmt.Errorf("nws_observation %q: params.url is required", cfg.Name)
|
||
}
|
||
|
||
ua, ok := cfg.ParamString("user_agent", "userAgent")
|
||
if !ok {
|
||
return nil, fmt.Errorf("nws_observation %q: params.user_agent is required", cfg.Name)
|
||
}
|
||
|
||
// A small timeout is good hygiene for daemons: you want polls to fail fast,
|
||
// not hang forever and block subsequent ticks.
|
||
client := &http.Client{
|
||
Timeout: 10 * time.Second,
|
||
}
|
||
|
||
return &ObservationSource{
|
||
name: cfg.Name,
|
||
url: url,
|
||
userAgent: ua,
|
||
client: client,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ObservationSource) Name() string { return s.name }
|
||
|
||
// Kind is used for routing/policy.
|
||
func (s *ObservationSource) Kind() event.Kind { return event.Kind("observation") }
|
||
|
||
// Poll fetches "current conditions" and emits exactly one Event (under normal conditions).
|
||
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
|
||
obs, eventID, err := s.fetchAndParse(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// EffectiveAt is optional.
|
||
// For observations, the natural effective time is the observation timestamp.
|
||
var effectiveAt *time.Time
|
||
if !obs.Timestamp.IsZero() {
|
||
t := obs.Timestamp
|
||
effectiveAt = &t
|
||
}
|
||
|
||
e := event.Event{
|
||
ID: eventID,
|
||
Kind: s.Kind(),
|
||
Source: s.name,
|
||
EmittedAt: time.Now().UTC(),
|
||
EffectiveAt: effectiveAt,
|
||
|
||
// Optional: makes downstream decoding/inspection easier.
|
||
Schema: "weather.observation.v1",
|
||
|
||
// Payload remains domain-specific for now.
|
||
Payload: obs,
|
||
}
|
||
|
||
if err := e.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return []event.Event{e}, nil
|
||
}
|
||
|
||
// --- JSON parsing (minimal model of NWS observation payload) ---
|
||
|
||
type nwsObservationResponse struct {
|
||
ID string `json:"id"` // a stable unique identifier URL in the payload you pasted
|
||
Properties struct {
|
||
StationID string `json:"stationId"`
|
||
StationName string `json:"stationName"`
|
||
Timestamp string `json:"timestamp"`
|
||
TextDescription string `json:"textDescription"`
|
||
Icon string `json:"icon"`
|
||
|
||
RawMessage string `json:"rawMessage"`
|
||
|
||
Elevation struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"elevation"`
|
||
|
||
Temperature struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"temperature"`
|
||
|
||
Dewpoint struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"dewpoint"`
|
||
|
||
WindDirection struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"windDirection"`
|
||
|
||
WindSpeed struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"windSpeed"`
|
||
|
||
WindGust struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"windGust"`
|
||
|
||
BarometricPressure struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"barometricPressure"`
|
||
|
||
SeaLevelPressure struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"seaLevelPressure"`
|
||
|
||
Visibility struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"visibility"`
|
||
|
||
RelativeHumidity struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"relativeHumidity"`
|
||
|
||
WindChill struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"windChill"`
|
||
|
||
HeatIndex struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"heatIndex"`
|
||
|
||
// NWS returns "presentWeather" as decoded METAR phenomena objects.
|
||
// We decode these initially as generic maps so we can:
|
||
// 1) preserve the raw objects in model.PresentWeather{Raw: ...}
|
||
// 2) also decode them into a typed struct for our WMO mapping logic.
|
||
PresentWeather []map[string]any `json:"presentWeather"`
|
||
|
||
CloudLayers []struct {
|
||
Base struct {
|
||
UnitCode string `json:"unitCode"`
|
||
Value *float64 `json:"value"`
|
||
} `json:"base"`
|
||
Amount string `json:"amount"`
|
||
} `json:"cloudLayers"`
|
||
} `json:"properties"`
|
||
}
|
||
|
||
// metarPhenomenon is a typed view of NWS presentWeather objects.
|
||
// You provided the schema for these values (intensity/modifier/weather/rawString).
|
||
type metarPhenomenon struct {
|
||
Intensity *string `json:"intensity"` // "light", "heavy", or null
|
||
Modifier *string `json:"modifier"` // "freezing", "showers", etc., or null
|
||
Weather string `json:"weather"` // e.g., "rain", "snow", "fog_mist", ...
|
||
RawString string `json:"rawString"`
|
||
// InVicinity exists in the schema; we ignore it for now because WMO codes
|
||
// don't directly represent "in vicinity" semantics.
|
||
InVicinity *bool `json:"inVicinity"`
|
||
}
|
||
|
||
func (s *ObservationSource) fetchAndParse(ctx context.Context) (model.WeatherObservation, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
|
||
if err != nil {
|
||
return model.WeatherObservation{}, "", err
|
||
}
|
||
|
||
// NWS requests: a real User-Agent with contact info is strongly recommended.
|
||
req.Header.Set("User-Agent", s.userAgent)
|
||
req.Header.Set("Accept", "application/geo+json, application/json")
|
||
|
||
res, err := s.client.Do(req)
|
||
if err != nil {
|
||
return model.WeatherObservation{}, "", err
|
||
}
|
||
defer res.Body.Close()
|
||
|
||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||
return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: HTTP %s", s.name, res.Status)
|
||
}
|
||
|
||
var parsed nwsObservationResponse
|
||
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
|
||
return model.WeatherObservation{}, "", err
|
||
}
|
||
|
||
// Parse timestamp (RFC3339)
|
||
var ts time.Time
|
||
if strings.TrimSpace(parsed.Properties.Timestamp) != "" {
|
||
t, err := time.Parse(time.RFC3339, parsed.Properties.Timestamp)
|
||
if err != nil {
|
||
return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: invalid timestamp %q: %w",
|
||
s.name, parsed.Properties.Timestamp, err)
|
||
}
|
||
ts = t
|
||
}
|
||
|
||
cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers))
|
||
for _, cl := range parsed.Properties.CloudLayers {
|
||
cloudLayers = append(cloudLayers, model.CloudLayer{
|
||
BaseMeters: cl.Base.Value,
|
||
Amount: cl.Amount,
|
||
})
|
||
}
|
||
|
||
// Preserve the raw presentWeather objects (as before) in the domain model.
|
||
present := make([]model.PresentWeather, 0, len(parsed.Properties.PresentWeather))
|
||
for _, pw := range parsed.Properties.PresentWeather {
|
||
present = append(present, model.PresentWeather{Raw: pw})
|
||
}
|
||
|
||
// Decode presentWeather into a typed slice for improved mapping.
|
||
phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather)
|
||
|
||
// Provider description (NWS vocabulary). We store this for troubleshooting only.
|
||
providerDesc := strings.TrimSpace(parsed.Properties.TextDescription)
|
||
|
||
// Map NWS -> canonical WMO code using best-effort heuristics:
|
||
// 1) presentWeather (METAR phenomena) if present
|
||
// 2) provider textDescription keywords
|
||
// 3) cloud layers fallback
|
||
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
|
||
|
||
// Canonical text comes from our shared WMO table.
|
||
// NWS does not give us an explicit day/night flag here, so we leave it nil.
|
||
canonicalText := standards.WMOText(wmo, nil)
|
||
|
||
obs := model.WeatherObservation{
|
||
StationID: parsed.Properties.StationID,
|
||
StationName: parsed.Properties.StationName,
|
||
Timestamp: ts,
|
||
|
||
// Canonical conditions
|
||
ConditionCode: wmo,
|
||
ConditionText: canonicalText,
|
||
IsDay: nil,
|
||
|
||
// Provider evidence (for troubleshooting mapping)
|
||
ProviderRawDescription: providerDesc,
|
||
|
||
// Human-facing fields:
|
||
// Populate TextDescription with canonical text so downstream output stays consistent.
|
||
TextDescription: canonicalText,
|
||
IconURL: parsed.Properties.Icon,
|
||
|
||
TemperatureC: parsed.Properties.Temperature.Value,
|
||
DewpointC: parsed.Properties.Dewpoint.Value,
|
||
|
||
WindDirectionDegrees: parsed.Properties.WindDirection.Value,
|
||
WindSpeedKmh: parsed.Properties.WindSpeed.Value,
|
||
WindGustKmh: parsed.Properties.WindGust.Value,
|
||
|
||
BarometricPressurePa: parsed.Properties.BarometricPressure.Value,
|
||
SeaLevelPressurePa: parsed.Properties.SeaLevelPressure.Value,
|
||
VisibilityMeters: parsed.Properties.Visibility.Value,
|
||
|
||
RelativeHumidityPercent: parsed.Properties.RelativeHumidity.Value,
|
||
WindChillC: parsed.Properties.WindChill.Value,
|
||
HeatIndexC: parsed.Properties.HeatIndex.Value,
|
||
|
||
ElevationMeters: parsed.Properties.Elevation.Value,
|
||
RawMessage: parsed.Properties.RawMessage,
|
||
|
||
PresentWeather: present,
|
||
CloudLayers: cloudLayers,
|
||
}
|
||
|
||
// Event ID: prefer the NWS-provided "id" (stable unique URL), else fall back to computed.
|
||
eventID := strings.TrimSpace(parsed.ID)
|
||
if eventID == "" {
|
||
eventID = fmt.Sprintf("observation:%s:%s:%s",
|
||
s.name,
|
||
obs.StationID,
|
||
obs.Timestamp.UTC().Format(time.RFC3339Nano),
|
||
)
|
||
}
|
||
|
||
return obs, eventID, nil
|
||
}
|
||
|
||
func decodeMetarPhenomena(raw []map[string]any) []metarPhenomenon {
|
||
if len(raw) == 0 {
|
||
return nil
|
||
}
|
||
|
||
out := make([]metarPhenomenon, 0, len(raw))
|
||
for _, m := range raw {
|
||
// Encode/decode is slightly inefficient, but it's simple and very readable.
|
||
// presentWeather payloads are small; this is fine for a polling daemon.
|
||
b, err := json.Marshal(m)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
var p metarPhenomenon
|
||
if err := json.Unmarshal(b, &p); err != nil {
|
||
continue
|
||
}
|
||
|
||
p.Weather = strings.ToLower(strings.TrimSpace(p.Weather))
|
||
p.RawString = strings.TrimSpace(p.RawString)
|
||
out = append(out, p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// mapNWSToWMO maps NWS signals into a canonical WMO code.
|
||
//
|
||
// Precedence:
|
||
// 1. METAR phenomena (presentWeather) — most reliable for precip/hazards
|
||
// 2. textDescription keywords — weaker, but still useful
|
||
// 3. cloud layers fallback — only for sky-only conditions
|
||
func mapNWSToWMO(providerDesc string, cloudLayers []model.CloudLayer, phenomena []metarPhenomenon) model.WMOCode {
|
||
// 1) Prefer METAR phenomena if present.
|
||
if code := wmoFromPhenomena(phenomena); code != model.WMOUnknown {
|
||
return code
|
||
}
|
||
|
||
// 2) Fall back to provider textDescription keywords.
|
||
if code := wmoFromTextDescription(providerDesc); code != model.WMOUnknown {
|
||
return code
|
||
}
|
||
|
||
// 3) Fall back to cloud layers.
|
||
if code := wmoFromCloudLayers(cloudLayers); code != model.WMOUnknown {
|
||
return code
|
||
}
|
||
|
||
return model.WMOUnknown
|
||
}
|
||
|
||
func wmoFromPhenomena(phenomena []metarPhenomenon) model.WMOCode {
|
||
if len(phenomena) == 0 {
|
||
return model.WMOUnknown
|
||
}
|
||
|
||
// Helper accessors (avoid repeating nil checks everywhere).
|
||
intensityOf := func(p metarPhenomenon) string {
|
||
if p.Intensity == nil {
|
||
return ""
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(*p.Intensity))
|
||
}
|
||
modifierOf := func(p metarPhenomenon) string {
|
||
if p.Modifier == nil {
|
||
return ""
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(*p.Modifier))
|
||
}
|
||
|
||
// Pass 1: thunder + hail overrides everything (hazard).
|
||
//
|
||
// WMO provides:
|
||
// 95 = thunderstorm
|
||
// 96 = light thunderstorms with hail
|
||
// 99 = thunderstorms with hail
|
||
hasThunder := false
|
||
hailIntensity := ""
|
||
for _, p := range phenomena {
|
||
switch p.Weather {
|
||
case "thunderstorms":
|
||
hasThunder = true
|
||
case "hail":
|
||
if hailIntensity == "" {
|
||
hailIntensity = intensityOf(p)
|
||
}
|
||
}
|
||
}
|
||
if hasThunder {
|
||
if hailIntensity != "" || containsWeather(phenomena, "hail") {
|
||
if hailIntensity == "heavy" {
|
||
return 99
|
||
}
|
||
// Default to "light" hail when unknown
|
||
return 96
|
||
}
|
||
return 95
|
||
}
|
||
|
||
// Pass 2: freezing hazards.
|
||
//
|
||
// Modifier includes "freezing".
|
||
for _, p := range phenomena {
|
||
if modifierOf(p) != "freezing" {
|
||
continue
|
||
}
|
||
|
||
switch p.Weather {
|
||
case "rain":
|
||
if intensityOf(p) == "light" {
|
||
return 66
|
||
}
|
||
// Default to freezing rain when unknown/heavy.
|
||
return 67
|
||
|
||
case "drizzle":
|
||
if intensityOf(p) == "light" {
|
||
return 56
|
||
}
|
||
return 57
|
||
|
||
case "fog", "fog_mist":
|
||
// "Freezing fog" isn't a perfect match for "Rime Fog",
|
||
// but within our current WMO subset, 48 is the closest.
|
||
return 48
|
||
}
|
||
}
|
||
|
||
// Pass 3: fog / obscuration.
|
||
for _, p := range phenomena {
|
||
switch p.Weather {
|
||
case "fog", "fog_mist":
|
||
return 45
|
||
case "haze", "smoke", "dust", "sand", "spray", "volcanic_ash":
|
||
// Our current WMO table subset doesn't include haze/smoke/dust codes.
|
||
// "Foggy" (45) is a reasonable umbrella for "visibility obscured".
|
||
return 45
|
||
}
|
||
}
|
||
|
||
// Pass 4: precip families.
|
||
for _, p := range phenomena {
|
||
inten := intensityOf(p)
|
||
mod := modifierOf(p)
|
||
|
||
// Handle "showers" modifier explicitly (rain vs snow showers).
|
||
if mod == "showers" {
|
||
switch p.Weather {
|
||
case "rain":
|
||
if inten == "light" {
|
||
return 80
|
||
}
|
||
if inten == "heavy" {
|
||
return 82
|
||
}
|
||
return 81
|
||
|
||
case "snow":
|
||
if inten == "light" {
|
||
return 85
|
||
}
|
||
return 86
|
||
}
|
||
}
|
||
|
||
switch p.Weather {
|
||
// Drizzle
|
||
case "drizzle":
|
||
if inten == "heavy" {
|
||
return 55
|
||
}
|
||
if inten == "light" {
|
||
return 51
|
||
}
|
||
return 53
|
||
|
||
// Rain
|
||
case "rain":
|
||
if inten == "heavy" {
|
||
return 65
|
||
}
|
||
if inten == "light" {
|
||
return 61
|
||
}
|
||
return 63
|
||
|
||
// Snow
|
||
case "snow":
|
||
if inten == "heavy" {
|
||
return 75
|
||
}
|
||
if inten == "light" {
|
||
return 71
|
||
}
|
||
return 73
|
||
|
||
// Snow grains
|
||
case "snow_grains":
|
||
return 77
|
||
|
||
// We don’t currently have sleet/ice pellet codes in our shared WMO subset.
|
||
// We make conservative choices within the available codes.
|
||
case "ice_pellets", "snow_pellets":
|
||
// Closest within our subset is "Snow" (73). If you later expand the WMO table
|
||
// to include sleet/ice pellet codes, update this mapping.
|
||
return 73
|
||
}
|
||
}
|
||
|
||
return model.WMOUnknown
|
||
}
|
||
|
||
func containsWeather(phenomena []metarPhenomenon, weather string) bool {
|
||
weather = strings.ToLower(strings.TrimSpace(weather))
|
||
for _, p := range phenomena {
|
||
if p.Weather == weather {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func wmoFromTextDescription(providerDesc string) model.WMOCode {
|
||
s := strings.ToLower(strings.TrimSpace(providerDesc))
|
||
if s == "" {
|
||
return model.WMOUnknown
|
||
}
|
||
|
||
// Thunder / hail
|
||
if strings.Contains(s, "thunder") {
|
||
if strings.Contains(s, "hail") {
|
||
return 99
|
||
}
|
||
return 95
|
||
}
|
||
|
||
// Freezing hazards
|
||
if strings.Contains(s, "freezing rain") {
|
||
if strings.Contains(s, "light") {
|
||
return 66
|
||
}
|
||
return 67
|
||
}
|
||
if strings.Contains(s, "freezing drizzle") {
|
||
if strings.Contains(s, "light") {
|
||
return 56
|
||
}
|
||
return 57
|
||
}
|
||
|
||
// Drizzle
|
||
if strings.Contains(s, "drizzle") {
|
||
if strings.Contains(s, "heavy") || strings.Contains(s, "dense") {
|
||
return 55
|
||
}
|
||
if strings.Contains(s, "light") {
|
||
return 51
|
||
}
|
||
return 53
|
||
}
|
||
|
||
// Showers
|
||
if strings.Contains(s, "showers") {
|
||
if strings.Contains(s, "heavy") {
|
||
return 82
|
||
}
|
||
if strings.Contains(s, "light") {
|
||
return 80
|
||
}
|
||
return 81
|
||
}
|
||
|
||
// Rain
|
||
if strings.Contains(s, "rain") {
|
||
if strings.Contains(s, "heavy") {
|
||
return 65
|
||
}
|
||
if strings.Contains(s, "light") {
|
||
return 61
|
||
}
|
||
return 63
|
||
}
|
||
|
||
// Snow
|
||
if strings.Contains(s, "snow showers") {
|
||
if strings.Contains(s, "light") {
|
||
return 85
|
||
}
|
||
return 86
|
||
}
|
||
if strings.Contains(s, "snow grains") {
|
||
return 77
|
||
}
|
||
if strings.Contains(s, "snow") {
|
||
if strings.Contains(s, "heavy") {
|
||
return 75
|
||
}
|
||
if strings.Contains(s, "light") {
|
||
return 71
|
||
}
|
||
return 73
|
||
}
|
||
|
||
// Fog
|
||
if strings.Contains(s, "rime fog") {
|
||
return 48
|
||
}
|
||
if strings.Contains(s, "fog") || strings.Contains(s, "mist") {
|
||
return 45
|
||
}
|
||
|
||
// Sky-only
|
||
if strings.Contains(s, "overcast") {
|
||
return 3
|
||
}
|
||
if strings.Contains(s, "cloudy") {
|
||
return 3
|
||
}
|
||
if strings.Contains(s, "partly cloudy") {
|
||
return 2
|
||
}
|
||
if strings.Contains(s, "mostly sunny") || strings.Contains(s, "mostly clear") ||
|
||
strings.Contains(s, "mainly sunny") || strings.Contains(s, "mainly clear") {
|
||
return 1
|
||
}
|
||
if strings.Contains(s, "clear") || strings.Contains(s, "sunny") {
|
||
return 0
|
||
}
|
||
|
||
return model.WMOUnknown
|
||
}
|
||
|
||
func wmoFromCloudLayers(cloudLayers []model.CloudLayer) model.WMOCode {
|
||
// NWS cloud layer amount values commonly include:
|
||
// OVC, BKN, SCT, FEW, SKC, CLR, VV (vertical visibility / obscured sky)
|
||
//
|
||
// We interpret these conservatively:
|
||
// - OVC / BKN / VV => Cloudy (3)
|
||
// - SCT => Partly Cloudy (2)
|
||
// - FEW => Mainly Sunny/Clear (1)
|
||
// - CLR / SKC => Sunny/Clear (0)
|
||
//
|
||
// If multiple layers exist, we bias toward the "most cloudy" layer.
|
||
mostCloudy := ""
|
||
|
||
for _, cl := range cloudLayers {
|
||
a := strings.ToUpper(strings.TrimSpace(cl.Amount))
|
||
if a == "" {
|
||
continue
|
||
}
|
||
|
||
switch a {
|
||
case "OVC":
|
||
return 3
|
||
case "BKN", "VV":
|
||
if mostCloudy != "OVC" {
|
||
mostCloudy = a
|
||
}
|
||
case "SCT":
|
||
if mostCloudy == "" {
|
||
mostCloudy = "SCT"
|
||
}
|
||
case "FEW":
|
||
if mostCloudy == "" {
|
||
mostCloudy = "FEW"
|
||
}
|
||
case "CLR", "SKC":
|
||
if mostCloudy == "" {
|
||
mostCloudy = "CLR"
|
||
}
|
||
}
|
||
}
|
||
|
||
switch mostCloudy {
|
||
case "BKN", "VV":
|
||
return 3
|
||
case "SCT":
|
||
return 2
|
||
case "FEW":
|
||
return 1
|
||
case "CLR":
|
||
return 0
|
||
default:
|
||
return model.WMOUnknown
|
||
}
|
||
}
|