diff options
| -rw-r--r-- | calculator.go | 134 | ||||
| -rw-r--r-- | converter.go | 138 | ||||
| -rw-r--r-- | fmi_client.go | 88 | ||||
| -rw-r--r-- | forecast_test.go | 81 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 401 | ||||
| -rw-r--r-- | models.go | 96 | ||||
| -rw-r--r-- | parser.go | 129 | ||||
| -rwxr-xr-x | weather | bin | 0 -> 8840466 bytes | |||
| -rw-r--r-- | weather_mapper.go | 63 |
10 files changed, 739 insertions, 394 deletions
diff --git a/calculator.go b/calculator.go new file mode 100644 index 0000000..84ed8c8 --- /dev/null +++ b/calculator.go @@ -0,0 +1,134 @@ +// calculator.go - Weather calculations +package main + +import ( + "math" + "time" +) + +// CalculateFeelsLike calculates apparent temperature +func CalculateFeelsLike(temp, humidity, windSpeed float64) float64 { + if temp <= 10 && windSpeed > 1.34 { + // Wind chill formula for cold temperatures + return 13.12 + 0.6215*temp - 11.37*math.Pow(windSpeed, 0.16) + 0.3965*temp*math.Pow(windSpeed, 0.16) + } else if temp >= 27 { + // Simplified heat index for warm temperatures + e := humidity / 100 * 6.105 * math.Exp(17.27*temp/(237.7+temp)) + return temp + 0.33*e - 0.70*windSpeed - 4.00 + } + return temp +} + +// SunCalculator calculates sunrise and sunset times +type SunCalculator struct { + latitude float64 + longitude float64 + utcOffset float64 +} + +// NewSunCalculator creates a new sun calculator +func NewSunCalculator(latitude, longitude, utcOffset float64) *SunCalculator { + return &SunCalculator{ + latitude: latitude, + longitude: longitude, + utcOffset: utcOffset, + } +} + +// Calculate computes sunrise and sunset for a given date +func (sc *SunCalculator) Calculate(date time.Time) (sunrise, sunset time.Time, err error) { + julianDay := sc.dateToJulianDay(date) + julianCentury := (julianDay - 2451545) / 36525.0 + + geomMeanLongSun := math.Mod(280.46646+julianCentury*(36000.76983+julianCentury*0.0003032), 360) + geomMeanAnomSun := 357.52911 + julianCentury*(35999.05029-0.0001537*julianCentury) + eccentEarthOrbit := 0.016708634 - julianCentury*(0.000042037+0.0000001267*julianCentury) + + sunEqOfCtr := math.Sin(degToRad(geomMeanAnomSun))*(1.914602-julianCentury*(0.004817+0.000014*julianCentury)) + + math.Sin(degToRad(2*geomMeanAnomSun))*(0.019993-0.000101*julianCentury) + + math.Sin(degToRad(3*geomMeanAnomSun))*0.000289 + + sunTrueLong := geomMeanLongSun + sunEqOfCtr + sunAppLong := sunTrueLong - 0.00569 - 0.00478*math.Sin(degToRad(125.04-1934.136*julianCentury)) + + meanObliqEcliptic := 23 + (26+(21.448-julianCentury*(46.815+julianCentury*(0.00059-julianCentury*0.001813)))/60)/60 + obliqCorr := meanObliqEcliptic + 0.00256*math.Cos(degToRad(125.04-1934.136*julianCentury)) + sunDeclin := radToDeg(math.Asin(math.Sin(degToRad(obliqCorr)) * math.Sin(degToRad(sunAppLong)))) + + varY := math.Tan(degToRad(obliqCorr/2)) * math.Tan(degToRad(obliqCorr/2)) + + eqOfTime := 4 * radToDeg(varY*math.Sin(2*degToRad(geomMeanLongSun))- + 2*eccentEarthOrbit*math.Sin(degToRad(geomMeanAnomSun))+ + 4*eccentEarthOrbit*varY*math.Sin(degToRad(geomMeanAnomSun))*math.Cos(2*degToRad(geomMeanLongSun))- + 0.5*varY*varY*math.Sin(4*degToRad(geomMeanLongSun))- + 1.25*eccentEarthOrbit*eccentEarthOrbit*math.Sin(2*degToRad(geomMeanAnomSun))) + + haSunrise := radToDeg(math.Acos(math.Cos(degToRad(90.833))/(math.Cos(degToRad(sc.latitude))*math.Cos(degToRad(sunDeclin))) - + math.Tan(degToRad(sc.latitude))*math.Tan(degToRad(sunDeclin)))) + + solarNoon := (720 - 4*sc.longitude - eqOfTime + sc.utcOffset*60) / 1440 + sunriseTime := solarNoon - haSunrise*4/1440 + sunsetTime := solarNoon + haSunrise*4/1440 + + return sc.julianDayToDate(julianDay + sunriseTime), sc.julianDayToDate(julianDay + sunsetTime), nil +} + +func (sc *SunCalculator) dateToJulianDay(date time.Time) float64 { + year := float64(date.Year()) + month := float64(date.Month()) + day := float64(date.Day()) + + if month <= 2 { + year -= 1 + month += 12 + } + + a := math.Floor(year / 100) + b := 2 - a + math.Floor(a/4) + + return math.Floor(365.25*(year+4716)) + math.Floor(30.6001*(month+1)) + day + b - 1524.5 +} + +func (sc *SunCalculator) julianDayToDate(julianDay float64) time.Time { + julianDay += 0.5 + z := math.Floor(julianDay) + f := julianDay - z + + var a float64 + if z >= 2299161 { + alpha := math.Floor((z - 1867216.25) / 36524.25) + a = z + 1 + alpha - math.Floor(alpha/4) + } else { + a = z + } + + b := a + 1524 + c := math.Floor((b - 122.1) / 365.25) + d := math.Floor(365.25 * c) + e := math.Floor((b - d) / 30.6001) + + day := b - d - math.Floor(30.6001*e) + f + month := e - 1 + if month > 12 { + month -= 12 + } + year := c - 4715 + if month > 2 { + year -= 1 + } + + hour := int(math.Floor((day - math.Floor(day)) * 24)) + minute := int(math.Floor(((day-math.Floor(day))*24 - float64(hour)) * 60)) + second := int(math.Floor((((day-math.Floor(day))*24-float64(hour))*60 - float64(minute)) * 60)) + + return time.Date(int(year), time.Month(month), int(math.Floor(day)), hour, minute, second, 0, time.UTC). + Add(time.Hour * time.Duration(sc.utcOffset)) +} + +func degToRad(deg float64) float64 { + return deg * math.Pi / 180 +} + +func radToDeg(rad float64) float64 { + return rad * 180 / math.Pi +} diff --git a/converter.go b/converter.go new file mode 100644 index 0000000..0e2b74c --- /dev/null +++ b/converter.go @@ -0,0 +1,138 @@ +// converter.go - Converts ForecastData to OWMResponse +package main + +import ( + "fmt" + "math" + "time" +) + +// ToOWMResponse converts ForecastData to OWMResponse format +func (fd *ForecastData) ToOWMResponse(mapper *WeatherMapper) (*OWMResponse, error) { + loc, err := time.LoadLocation(fd.Timezone) + if err != nil { + return nil, fmt.Errorf("loading timezone: %w", err) + } + + // Calculate timezone offset + firstTime := time.Unix(fd.Timestamps[0], 0).UTC() + _, offset := firstTime.In(loc).Zone() + + // Create response + response := &OWMResponse{ + Lat: fd.Latitude, + Lon: fd.Longitude, + Timezone: fd.Timezone, + TimezoneOffset: offset, + } + + // Calculate sunrise/sunset for first day + sunCalc := NewSunCalculator(fd.Latitude, fd.Longitude, float64(offset)/3600) + sunrise, sunset, err := sunCalc.Calculate(firstTime) + if err != nil { + // Use defaults if calculation fails + sunrise = firstTime.Add(8 * time.Hour) + sunset = firstTime.Add(16 * time.Hour) + } + + // Convert each forecast point + hourly := make([]Current, 0, len(fd.Timestamps)) + for i, timestamp := range fd.Timestamps { + current, err := fd.convertToCurrent(i, timestamp, loc, sunrise, sunset, mapper) + if err != nil { + return nil, fmt.Errorf("converting point %d: %w", i, err) + } + hourly = append(hourly, current) + } + + response.Current = hourly[0] + response.Current.Sunrise = sunrise.Unix() + response.Current.Sunset = sunset.Unix() + response.Hourly = hourly + + return response, nil +} + +func (fd *ForecastData) convertToCurrent(index int, timestamp int64, loc *time.Location, sunrise, sunset time.Time, mapper *WeatherMapper) (Current, error) { + current := Current{ + Dt: timestamp, + Uvi: 0, // Not available from FMI + } + + // Add ISO 8601 local time + current.LocalTime = time.Unix(timestamp, 0).In(loc).Format(time.RFC3339) + + // Extract temperature + if val, err := fd.getFloatValue(index, "Temperature"); err == nil && !math.IsNaN(val) { + current.Temp = val + } + + // Extract humidity + if val, err := fd.getFloatValue(index, "Humidity"); err == nil && !math.IsNaN(val) { + current.Humidity = int(math.Round(val)) + } + + // Extract pressure + if val, err := fd.getFloatValue(index, "Pressure"); err == nil && !math.IsNaN(val) { + current.Pressure = int(math.Round(val)) + } + + // Extract dew point + if val, err := fd.getFloatValue(index, "dewPoint"); err == nil && !math.IsNaN(val) { + current.DewPoint = val + } + + // Extract wind speed + if val, err := fd.getFloatValue(index, "WindSpeedMS"); err == nil && !math.IsNaN(val) { + current.WindSpeed = val + } + + // Extract wind direction + if val, err := fd.getFloatValue(index, "WindDirection"); err == nil && !math.IsNaN(val) { + current.WindDeg = int(math.Round(val)) + } + + // Extract wind gust + if val, err := fd.getFloatValue(index, "WindGust"); err == nil && !math.IsNaN(val) { + current.WindGust = val + } + + // Extract cloud cover + if val, err := fd.getFloatValue(index, "TotalCloudCover"); err == nil && !math.IsNaN(val) { + current.Clouds = int(math.Round(val)) + } + + // Extract visibility + if val, err := fd.getFloatValue(index, "visibility"); err == nil && !math.IsNaN(val) { + current.Visibility = int(math.Round(val)) + } + + // Calculate feels-like temperature + current.FeelsLike = CalculateFeelsLike(current.Temp, float64(current.Humidity), current.WindSpeed) + + // Handle precipitation + if val, err := fd.getFloatValue(index, "PrecipitationRate"); err == nil && !math.IsNaN(val) && val > 0 { + current.Rain = &Rain{OneH: val} + } + + // Map weather symbol + if val, err := fd.getFloatValue(index, "WeatherSymbol3"); err == nil && !math.IsNaN(val) { + symbol := int(math.Round(val)) + forecastTime := time.Unix(timestamp, 0) + current.Weather = []Weather{mapper.Map(symbol, forecastTime, sunrise, sunset)} + } else { + // Default weather + current.Weather = []Weather{{800, "Clear", "clear sky", "01d"}} + } + + return current, nil +} + +func (fd *ForecastData) getFloatValue(rowIndex int, paramName string) (float64, error) { + if paramIndex, ok := fd.ParamIndex[paramName]; ok { + if rowIndex < len(fd.Values) && paramIndex < len(fd.Values[rowIndex]) { + return fd.Values[rowIndex][paramIndex], nil + } + } + return math.NaN(), fmt.Errorf("parameter %s not found or out of bounds", paramName) +} diff --git a/fmi_client.go b/fmi_client.go new file mode 100644 index 0000000..e4b59b2 --- /dev/null +++ b/fmi_client.go @@ -0,0 +1,88 @@ +// fmi_client.go - FMI API client library +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// FMIClient handles FMI API interactions +type FMIClient struct { + BaseURL string + HTTPClient *http.Client + WeatherMapper *WeatherMapper +} + +// NewFMIClient creates a new FMI client +func NewFMIClient() *FMIClient { + return &FMIClient{ + BaseURL: "https://opendata.fmi.fi/wfs", + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + WeatherMapper: NewWeatherMapper(), + } +} + +// GetForecast fetches and parses forecast for a location +func (c *FMIClient) GetForecast(location string) (*OWMResponse, error) { + // Build request URL + url, err := c.buildURL(location) + if err != nil { + return nil, fmt.Errorf("building URL: %w", err) + } + + // Fetch XML data + xmlData, err := c.fetchXML(url) + if err != nil { + return nil, fmt.Errorf("fetching XML: %w", err) + } + + // Parse XML + forecastData, err := ParseForecastXML(xmlData) + if err != nil { + return nil, fmt.Errorf("parsing XML: %w", err) + } + + // Convert to OWM format + response, err := forecastData.ToOWMResponse(c.WeatherMapper) + if err != nil { + return nil, fmt.Errorf("converting to OWM format: %w", err) + } + + return response, nil +} + +func (c *FMIClient) buildURL(location string) (string, error) { + baseURL, err := url.Parse(c.BaseURL) + if err != nil { + return "", err + } + + q := baseURL.Query() + q.Set("service", "WFS") + q.Set("version", "2.0.0") + q.Set("request", "getFeature") + q.Set("storedquery_id", "fmi::forecast::harmonie::surface::point::multipointcoverage") + q.Set("place", location) + q.Set("parameters", "Temperature,PrecipitationRate,Humidity,Pressure,dewPoint,visibility,WindSpeedMS,WindDirection,WindGust,TotalCloudCover,WeatherSymbol3") + + baseURL.RawQuery = q.Encode() + return baseURL.String(), nil +} + +func (c *FMIClient) fetchXML(url string) ([]byte, error) { + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + return io.ReadAll(resp.Body) +} diff --git a/forecast_test.go b/forecast_test.go new file mode 100644 index 0000000..06a3dc7 --- /dev/null +++ b/forecast_test.go @@ -0,0 +1,81 @@ +// forecast_test.go - Unit tests (example) +package main + +import ( + "math" + "testing" + "time" +) + +func TestCalculateFeelsLike(t *testing.T) { + tests := []struct { + name string + temp float64 + humidity float64 + windSpeed float64 + want float64 + }{ + {"Cold with wind", 5, 80, 10, 0.49}, // Approximate wind chill + {"Warm with humidity", 30, 80, 5, 32.65}, // Approximate heat index + {"Moderate", 20, 50, 5, 20}, // No adjustment + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateFeelsLike(tt.temp, tt.humidity, tt.windSpeed) + if math.Abs(got-tt.want) > 1.0 { // Allow 1 degree tolerance + t.Errorf("CalculateFeelsLike() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWeatherMapper(t *testing.T) { + mapper := NewWeatherMapper() + sunrise := time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC) + sunset := time.Date(2026, 2, 1, 16, 0, 0, 0, time.UTC) + + tests := []struct { + symbol int + time time.Time + wantID int + wantIcon string + }{ + {1, sunrise.Add(2 * time.Hour), 800, "01d"}, // Clear, daytime + {1, sunset.Add(2 * time.Hour), 800, "01n"}, // Clear, nighttime + {25, sunrise, 500, "10d"}, // Light rain, daytime + {91, sunset, 200, "11n"}, // Thunderstorm, nighttime + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + weather := mapper.Map(tt.symbol, tt.time, sunrise, sunset) + if weather.ID != tt.wantID { + t.Errorf("Weather.ID = %v, want %v", weather.ID, tt.wantID) + } + if weather.Icon != tt.wantIcon { + t.Errorf("Weather.Icon = %v, want %v", weather.Icon, tt.wantIcon) + } + }) + } +} + +func TestSunCalculator(t *testing.T) { + calculator := NewSunCalculator(60.169, 24.935, 2) // Helsinki + + date := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + sunrise, sunset, err := calculator.Calculate(date) + + if err != nil { + t.Errorf("SunCalculator.Calculate() error = %v", err) + } + + if sunrise.IsZero() || sunset.IsZero() { + t.Errorf("Sunrise or sunset is zero") + } + + // Sunrise should be before sunset + if !sunrise.Before(sunset) { + t.Errorf("Sunrise %v not before sunset %v", sunrise, sunset) + } +} @@ -0,0 +1,3 @@ +module tammi.cc/weather + +go 1.25.5 @@ -1,417 +1,30 @@ +// main.go - Main application entry point package main import ( "encoding/json" - "encoding/xml" "flag" "fmt" - "io" - "math" - "net/http" "os" - "strconv" - "strings" - "time" ) -type FeatureCollection struct { - XMLName xml.Name `xml:"http://www.opengis.net/wfs/2.0 FeatureCollection"` - Member []Member `xml:"http://www.opengis.net/wfs/2.0 member"` -} - -type Member struct { - Observation Observation `xml:"http://www.opengis.net/om/2.0 OM_Observation"` -} - -type Observation struct { - Result Result `xml:"http://www.opengis.net/om/2.0 result"` -} - -type Result struct { - MultiPointCoverage MultiPointCoverage `xml:"http://www.opengis.net/gmlcov/1.0 MultiPointCoverage"` -} - -type MultiPointCoverage struct { - DomainSet DomainSet `xml:"http://www.opengis.net/gml/3.2 domainSet"` - RangeSet RangeSet `xml:"http://www.opengis.net/gml/3.2 rangeSet"` - RangeType RangeType `xml:"http://www.opengis.net/gmlcov/1.0 rangeType"` -} - -type DomainSet struct { - MultiPoint MultiPoint `xml:"http://www.opengis.net/gml/3.2 MultiPoint"` -} - -type MultiPoint struct { - PosList string `xml:"http://www.opengis.net/gml/3.2 posList"` -} - -type RangeSet struct { - DataBlock DataBlock `xml:"http://www.opengis.net/gml/3.2 DataBlock"` -} - -type DataBlock struct { - TupleList string `xml:"http://www.opengis.net/gml/3.2 doubleOrNilReasonTupleList"` -} - -type RangeType struct { - DataRecord DataRecord `xml:"http://www.opengis.net/swe/2.0 DataRecord"` -} - -type DataRecord struct { - Field []Field `xml:"http://www.opengis.net/swe/2.0 field"` -} - -type Field struct { - Name string `xml:"name,attr"` -} - -type OWMResponse struct { - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Timezone string `json:"timezone"` - TimezoneOffset int `json:"timezone_offset"` - Current Current `json:"current"` - Hourly []Current `json:"hourly"` -} - -type Current struct { - Dt int64 `json:"dt"` - Sunrise int64 `json:"sunrise,omitempty"` - Sunset int64 `json:"sunset,omitempty"` - Temp float64 `json:"temp"` - FeelsLike float64 `json:"feels_like"` - Pressure int `json:"pressure"` - Humidity int `json:"humidity"` - DewPoint float64 `json:"dew_point"` - Uvi float64 `json:"uvi"` - Clouds int `json:"clouds"` - Visibility int `json:"visibility"` - WindSpeed float64 `json:"wind_speed"` - WindDeg int `json:"wind_deg"` - WindGust float64 `json:"wind_gust"` - Weather []Weather `json:"weather"` - Rain *Rain `json:"rain,omitempty"` -} - -type Rain struct { - OneH float64 `json:"1h"` -} - -type Weather struct { - ID int `json:"id"` - Main string `json:"main"` - Description string `json:"description"` - Icon string `json:"icon"` -} - -// Parameters for the sunrise and sunset calculation -type Parameters struct { - Latitude float64 - Longitude float64 - UtcOffset float64 - Date time.Time -} - -// GetSunriseSunset calculates the sunrise and sunset times -func (p Parameters) GetSunriseSunset() (time.Time, time.Time, error) { - // Convert the date to julian day - julianDay := p.DateToJulianDay() - - // Calculate the julian century - julianCentury := (julianDay - 2451545) / 36525.0 - - // Geom mean long sun (deg) - geomMeanLongSun := math.Mod(280.46646+julianCentury*(36000.76983+julianCentury*0.0003032), 360) - - // Geom mean anom sun (deg) - geomMeanAnomSun := 357.52911 + julianCentury*(35999.05029-0.0001537*julianCentury) - - // Eccent earth orbit - eccentEarthOrbit := 0.016708634 - julianCentury*(0.000042037+0.0000001267*julianCentury) - - // Sun eq of ctr - sunEqOfCtr := math.Sin(degToRad(geomMeanAnomSun))*(1.914602-julianCentury*(0.004817+0.000014*julianCentury)) + - math.Sin(degToRad(2*geomMeanAnomSun))*(0.019993-0.000101*julianCentury) + - math.Sin(degToRad(3*geomMeanAnomSun))*0.000289 - - // Sun true long (deg) - sunTrueLong := geomMeanLongSun + sunEqOfCtr - - // Sun app long (deg) - sunAppLong := sunTrueLong - 0.00569 - 0.00478*math.Sin(degToRad(125.04-1934.136*julianCentury)) - - // Mean obliq ecliptic (deg) - meanObliqEcliptic := 23 + (26+(21.448-julianCentury*(46.815+julianCentury*(0.00059-julianCentury*0.001813)))/60)/60 - - // Obliq corr (deg) - obliqCorr := meanObliqEcliptic + 0.00256*math.Cos(degToRad(125.04-1934.136*julianCentury)) - - // Sun declin (deg) - sunDeclin := radToDeg(math.Asin(math.Sin(degToRad(obliqCorr)) * math.Sin(degToRad(sunAppLong)))) - - // Var y - varY := math.Tan(degToRad(obliqCorr/2)) * math.Tan(degToRad(obliqCorr/2)) - - // Eq of time (minutes) - eqOfTime := 4 * radToDeg(varY*math.Sin(2*degToRad(geomMeanLongSun))- - 2*eccentEarthOrbit*math.Sin(degToRad(geomMeanAnomSun))+ - 4*eccentEarthOrbit*varY*math.Sin(degToRad(geomMeanAnomSun))*math.Cos(2*degToRad(geomMeanLongSun))- - 0.5*varY*varY*math.Sin(4*degToRad(geomMeanLongSun))- - 1.25*eccentEarthOrbit*eccentEarthOrbit*math.Sin(2*degToRad(geomMeanAnomSun))) - - // HA sunrise (deg) - haSunrise := radToDeg(math.Acos(math.Cos(degToRad(90.833))/(math.Cos(degToRad(p.Latitude))*math.Cos(degToRad(sunDeclin))) - math.Tan(degToRad(p.Latitude))*math.Tan(degToRad(sunDeclin)))) - - // Solar noon (LST) - solarNoon := (720 - 4*p.Longitude - eqOfTime + p.UtcOffset*60) / 1440 - - // Sunrise time (LST) - sunriseTime := solarNoon - haSunrise*4/1440 - - // Sunset time (LST) - sunsetTime := solarNoon + haSunrise*4/1440 - - // Convert the sunrise and sunset to time.Time - sunrise := p.julianDayToDate(julianDay + sunriseTime) - sunset := p.julianDayToDate(julianDay + sunsetTime) - - return sunrise, sunset, nil -} - -// DateToJulianDay converts a date to julian day -func (p Parameters) DateToJulianDay() float64 { - year := float64(p.Date.Year()) - month := float64(p.Date.Month()) - day := float64(p.Date.Day()) - - if month <= 2 { - year -= 1 - month += 12 - } - - a := math.Floor(year / 100) - b := 2 - a + math.Floor(a/4) - - return math.Floor(365.25*(year+4716)) + math.Floor(30.6001*(month+1)) + day + b - 1524.5 -} - -// julianDayToDate converts a julian day to date -func (p Parameters) julianDayToDate(julianDay float64) time.Time { - julianDay += 0.5 - z := math.Floor(julianDay) - f := julianDay - z - - var a float64 - if z >= 2299161 { - alpha := math.Floor((z - 1867216.25) / 36524.25) - a = z + 1 + alpha - math.Floor(alpha/4) - } else { - a = z - } - - b := a + 1524 - c := math.Floor((b - 122.1) / 365.25) - d := math.Floor(365.25 * c) - e := math.Floor((b - d) / 30.6001) - - day := b - d - math.Floor(30.6001*e) + f - month := e - 1 - if month > 12 { - month -= 12 - } - year := c - 4715 - if month > 2 { - year -= 1 - } - - hour := int(math.Floor((day - math.Floor(day)) * 24)) - minute := int(math.Floor(((day-math.Floor(day))*24 - float64(hour)) * 60)) - second := int(math.Floor((((day-math.Floor(day))*24-float64(hour))*60 - float64(minute)) * 60)) - - return time.Date(int(year), time.Month(month), int(math.Floor(day)), hour, minute, second, 0, time.UTC).Add(time.Hour * time.Duration(p.UtcOffset)) -} - -func degToRad(deg float64) float64 { - return deg * math.Pi / 180 -} - -func radToDeg(rad float64) float64 { - return rad * 180 / math.Pi -} - -// Map FMI WeatherSymbol3 to OWM weather -var fmiToOwm = map[int]Weather{ - 1: {800, "Clear", "clear sky", "01"}, - 2: {801, "Clouds", "few clouds", "02"}, - 3: {802, "Clouds", "scattered clouds", "03"}, - 4: {803, "Clouds", "broken clouds", "04"}, - 5: {804, "Clouds", "overcast clouds", "04"}, - // Add more mappings as needed, e.g. - 21: {520, "Rain", "light intensity shower rain", "09"}, - 22: {521, "Rain", "shower rain", "09"}, - 23: {522, "Rain", "heavy intensity shower rain", "09"}, - 30: {500, "Rain", "light rain", "10"}, - 31: {501, "Rain", "moderate rain", "10"}, - 32: {502, "Rain", "heavy intensity rain", "10"}, - // Snow - 41: {600, "Snow", "light snow", "13"}, - // Thunder - 91: {200, "Thunderstorm", "thunderstorm with light rain", "11"}, - // etc. Expand based on full list -} - -// CalculateFeelsLike simple apparent temperature -func calculateFeelsLike(temp, humidity float64, windSpeed float64) float64 { - e := humidity / 100 * 6.105 * math.Exp(17.27*temp/(237.7+temp)) - return temp + 0.33*e - 0.70*windSpeed - 4.00 -} - func main() { location := flag.String("place", "Helsinki", "Location for the forecast") flag.Parse() - url := fmt.Sprintf("https://opendata.fmi.fi/wfs?service=WFS&version=2.0.0&request=getFeature&storedquery_id=fmi::forecast::harmonie::surface::point::multipointcoverage&place=%s¶meters=Temperature,PrecipitationAmount,Humidity,Pressure,DewPointTemperature,WindSpeedMS,WindDirection,WindGust,TotalCloudCover,HorizontalVisibility,WeatherSymbol3", *location) - - resp, err := http.Get(url) + // Create client and get forecast + client := NewFMIClient() + response, err := client.GetForecast(*location) if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching data: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err) - os.Exit(1) - } - - var fc FeatureCollection - err = xml.Unmarshal(body, &fc) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing XML: %v\n", err) - os.Exit(1) - } - - if len(fc.Member) == 0 { - fmt.Fprintf(os.Stderr, "No data found\n") - os.Exit(1) - } - - mpc := fc.Member[0].Observation.Result.MultiPointCoverage - - pos := strings.Fields(mpc.DomainSet.MultiPoint.PosList) - if len(pos) < 3 { - fmt.Fprintf(os.Stderr, "Invalid posList\n") - os.Exit(1) - } - - lat, _ := strconv.ParseFloat(pos[0], 64) - lon, _ := strconv.ParseFloat(pos[1], 64) - - var times []int64 - for i := 2; i < len(pos); i += 3 { - t, _ := strconv.ParseInt(pos[i], 10, 64) - times = append(times, t) - } - - params := []string{} - for _, f := range mpc.RangeType.DataRecord.Field { - params = append(params, f.Name) - } - - paramIndex := make(map[string]int) - for i, p := range params { - paramIndex[p] = i - } - - valsStr := strings.Fields(strings.TrimSpace(mpc.RangeSet.DataBlock.TupleList)) - numPoints := len(times) - numParams := len(params) - if len(valsStr) != numPoints*numParams { - fmt.Fprintf(os.Stderr, "Data mismatch\n") - os.Exit(1) - } - - var values [][]float64 - for i := 0; i < numPoints; i++ { - row := make([]float64, numParams) - for j := 0; j < numParams; j++ { - row[j], _ = strconv.ParseFloat(valsStr[i*numParams+j], 64) - } - values = append(values, row) - } - - loc, _ := time.LoadLocation("Europe/Helsinki") - firstDt := time.Unix(times[0], 0).UTC() - _, offset := firstDt.In(loc).Zone() - - var response OWMResponse - response.Lat = lat - response.Lon = lon - response.Timezone = "Europe/Helsinki" - response.TimezoneOffset = offset - - // Calculate sunrise/sunset for the day of first forecast - p := Parameters{ - Latitude: lat, - Longitude: lon, - UtcOffset: float64(offset) / 3600, - Date: firstDt, - } - sunrise, sunset, _ := p.GetSunriseSunset() - response.Current.Sunrise = sunrise.Unix() - response.Current.Sunset = sunset.Unix() - - var hourly []Current - for i := 0; i < numPoints; i++ { - row := values[i] - dt := times[i] - var curr Current - curr.Dt = dt - curr.Temp = row[paramIndex["Temperature"]] - curr.Humidity = int(row[paramIndex["Humidity"]]) - curr.Pressure = int(row[paramIndex["Pressure"]]) - curr.DewPoint = row[paramIndex["DewPointTemperature"]] - curr.WindSpeed = row[paramIndex["WindSpeedMS"]] - curr.WindDeg = int(row[paramIndex["WindDirection"]]) - curr.WindGust = row[paramIndex["WindGust"]] - curr.Clouds = int(row[paramIndex["TotalCloudCover"]]) - curr.Visibility = int(row[paramIndex["HorizontalVisibility"]]) - curr.Uvi = 0 // Not available - curr.FeelsLike = calculateFeelsLike(curr.Temp, float64(curr.Humidity), curr.WindSpeed) - - precip := row[paramIndex["PrecipitationAmount"]] - if precip > 0 { - curr.Rain = &Rain{OneH: precip} - } - - symbol := int(row[paramIndex["WeatherSymbol3"]]) - w, ok := fmiToOwm[symbol] - if !ok { - w = Weather{741, "Fog", "unknown", "50"} // Default - } - - // Day or night - t := time.Unix(dt, 0).UTC() - isDay := t.After(sunrise) && t.Before(sunset) - if isDay { - w.Icon += "d" - } else { - w.Icon += "n" - } - curr.Weather = []Weather{w} - - hourly = append(hourly, curr) - } - - response.Current = hourly[0] - response.Hourly = hourly - - jsonData, err := json.Marshal(response) + // Output JSON + jsonData, err := json.MarshalIndent(response, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) os.Exit(1) } - fmt.Println(string(jsonData)) } diff --git a/models.go b/models.go new file mode 100644 index 0000000..6493b96 --- /dev/null +++ b/models.go @@ -0,0 +1,96 @@ +// models.go - Data models +package main + +import ( + "encoding/xml" +) + +// OWMResponse represents OpenWeatherMap API response format +type OWMResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Timezone string `json:"timezone"` + TimezoneOffset int `json:"timezone_offset"` + Current Current `json:"current"` + Hourly []Current `json:"hourly"` +} + +// Current represents current or hourly weather data +type Current struct { + Dt int64 `json:"dt"` + Sunrise int64 `json:"sunrise,omitempty"` + Sunset int64 `json:"sunset,omitempty"` + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + DewPoint float64 `json:"dew_point"` + Uvi float64 `json:"uvi"` + Clouds int `json:"clouds"` + Visibility int `json:"visibility"` + WindSpeed float64 `json:"wind_speed"` + WindDeg int `json:"wind_deg"` + WindGust float64 `json:"wind_gust"` + Weather []Weather `json:"weather"` + Rain *Rain `json:"rain,omitempty"` + LocalTime string `json:"local_time,omitempty"` // ISO 8601 format +} + +// Rain represents precipitation data +type Rain struct { + OneH float64 `json:"1h"` +} + +// Weather represents weather condition +type Weather struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +// ForecastData represents parsed FMI forecast data +type ForecastData struct { + Latitude float64 + Longitude float64 + Timezone string + Timestamps []int64 + Parameters []string + Values [][]float64 + ParamIndex map[string]int +} + +// XML models for FMI API response +type FeatureCollection struct { + XMLName xml.Name `xml:"http://www.opengis.net/wfs/2.0 FeatureCollection"` + Members []struct { + GridSeriesObservation struct { + XMLName xml.Name `xml:"GridSeriesObservation"` + Namespace string `xml:"xmlns,attr"` + Result struct { + MultiPointCoverage struct { + XMLName xml.Name `xml:"MultiPointCoverage"` + Namespace string `xml:"xmlns,attr"` + DomainSet struct { + SimpleMultiPoint struct { + XMLName xml.Name `xml:"SimpleMultiPoint"` + Positions string `xml:"positions"` + } `xml:"SimpleMultiPoint"` + } `xml:"domainSet"` + RangeSet struct { + DataBlock struct { + TupleList string `xml:"doubleOrNilReasonTupleList"` + } `xml:"DataBlock"` + } `xml:"rangeSet"` + RangeType struct { + DataRecord struct { + Fields []struct { + Name string `xml:"name,attr"` + } `xml:"field"` + } `xml:"DataRecord"` + } `xml:"rangeType"` + } `xml:"MultiPointCoverage"` + } `xml:"result"` + } `xml:"GridSeriesObservation"` + } `xml:"member"` +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..6cb67e8 --- /dev/null +++ b/parser.go @@ -0,0 +1,129 @@ +// parser.go - XML parsing and data extraction +package main + +import ( + "encoding/xml" + "fmt" + "math" + "strconv" + "strings" +) + +// ParseForecastXML parses XML response into ForecastData +func ParseForecastXML(xmlData []byte) (*ForecastData, error) { + var fc FeatureCollection + if err := xml.Unmarshal(xmlData, &fc); err != nil { + return nil, fmt.Errorf("unmarshaling XML: %w", err) + } + + if len(fc.Members) == 0 { + return nil, fmt.Errorf("no data found in response") + } + + mpc := fc.Members[0].GridSeriesObservation.Result.MultiPointCoverage + + // Parse positions + lat, lon, times, err := parsePositions(mpc.DomainSet.SimpleMultiPoint.Positions) + if err != nil { + return nil, fmt.Errorf("parsing positions: %w", err) + } + + // Parse parameters + params := make([]string, 0, len(mpc.RangeType.DataRecord.Fields)) + paramIndex := make(map[string]int) + for i, f := range mpc.RangeType.DataRecord.Fields { + params = append(params, f.Name) + paramIndex[f.Name] = i + } + + // Parse values + values, err := parseTupleList(mpc.RangeSet.DataBlock.TupleList, len(times), len(params)) + if err != nil { + return nil, fmt.Errorf("parsing values: %w", err) + } + + return &ForecastData{ + Latitude: lat, + Longitude: lon, + Timezone: "Europe/Helsinki", // Default, could be parsed from XML + Timestamps: times, + Parameters: params, + Values: values, + ParamIndex: paramIndex, + }, nil +} + +func parsePositions(positionsStr string) (lat, lon float64, times []int64, err error) { + lines := strings.Split(strings.TrimSpace(positionsStr), "\n") + + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) != 3 { + return 0, 0, nil, fmt.Errorf("invalid position format at line %d: %s", i, line) + } + + parsedLat, err := strconv.ParseFloat(parts[0], 64) + if err != nil { + return 0, 0, nil, fmt.Errorf("parsing latitude: %w", err) + } + + parsedLon, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + return 0, 0, nil, fmt.Errorf("parsing longitude: %w", err) + } + + timestamp, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return 0, 0, nil, fmt.Errorf("parsing timestamp: %w", err) + } + + if i == 0 { + lat, lon = parsedLat, parsedLon + } else if math.Abs(parsedLat-lat) > 0.001 || math.Abs(parsedLon-lon) > 0.001 { + return 0, 0, nil, fmt.Errorf("inconsistent location at line %d", i) + } + + times = append(times, timestamp) + } + + if len(times) == 0 { + return 0, 0, nil, fmt.Errorf("no positions found") + } + + return lat, lon, times, nil +} + +func parseTupleList(tupleStr string, numPoints, numParams int) ([][]float64, error) { + tupleStr = strings.TrimSpace(tupleStr) + valuesStr := strings.Fields(tupleStr) + + if len(valuesStr) != numPoints*numParams { + return nil, fmt.Errorf("data mismatch: expected %d values, got %d", + numPoints*numParams, len(valuesStr)) + } + + values := make([][]float64, numPoints) + for i := 0; i < numPoints; i++ { + row := make([]float64, numParams) + for j := 0; j < numParams; j++ { + valStr := valuesStr[i*numParams+j] + if valStr == "NaN" { + row[j] = math.NaN() + } else { + val, err := strconv.ParseFloat(valStr, 64) + if err != nil { + return nil, fmt.Errorf("parsing value at [%d][%d]: %w", i, j, err) + } + row[j] = val + } + } + values[i] = row + } + + return values, nil +} Binary files differdiff --git a/weather_mapper.go b/weather_mapper.go new file mode 100644 index 0000000..7f01217 --- /dev/null +++ b/weather_mapper.go @@ -0,0 +1,63 @@ +// weather_mapper.go - Maps FMI weather symbols to OWM format +package main + +import "time" + +// WeatherMapper handles weather symbol mapping +type WeatherMapper struct { + symbolMap map[int]Weather +} + +// NewWeatherMapper creates a new weather mapper +func NewWeatherMapper() *WeatherMapper { + return &WeatherMapper{ + symbolMap: map[int]Weather{ + 1: {800, "Clear", "clear sky", "01"}, + 2: {801, "Clouds", "few clouds", "02"}, + 3: {802, "Clouds", "scattered clouds", "03"}, + 4: {803, "Clouds", "broken clouds", "04"}, + 5: {804, "Clouds", "overcast clouds", "04"}, + 6: {701, "Mist", "mist", "50"}, + 7: {701, "Mist", "mist", "50"}, + 8: {741, "Fog", "fog", "50"}, + 9: {741, "Fog", "fog", "50"}, + 10: {741, "Fog", "fog", "50"}, + 11: {300, "Drizzle", "light intensity drizzle", "09"}, + 12: {301, "Drizzle", "drizzle", "09"}, + 13: {302, "Drizzle", "heavy intensity drizzle", "09"}, + 21: {520, "Rain", "light intensity shower rain", "09"}, + 22: {521, "Rain", "shower rain", "09"}, + 23: {522, "Rain", "heavy intensity shower rain", "09"}, + 24: {511, "Rain", "freezing rain", "13"}, + 25: {500, "Rain", "light rain", "10"}, + 26: {501, "Rain", "moderate rain", "10"}, + 27: {502, "Rain", "heavy intensity rain", "10"}, + 30: {500, "Rain", "light rain", "10"}, + 31: {501, "Rain", "moderate rain", "10"}, + 32: {502, "Rain", "heavy intensity rain", "10"}, + 40: {600, "Snow", "light snow", "13"}, + 41: {600, "Snow", "light snow", "13"}, + 42: {601, "Snow", "snow", "13"}, + 43: {602, "Snow", "heavy snow", "13"}, + 91: {200, "Thunderstorm", "thunderstorm with light rain", "11"}, + }, + } +} + +// Map converts FMI weather symbol to OWM weather with day/night icon +func (wm *WeatherMapper) Map(symbol int, forecastTime, sunrise, sunset time.Time) Weather { + weather, ok := wm.symbolMap[symbol] + if !ok { + weather = Weather{800, "Clear", "clear sky", "01"} + } + + // Determine day/night + isDay := forecastTime.After(sunrise) && forecastTime.Before(sunset) + if isDay { + weather.Icon += "d" + } else { + weather.Icon += "n" + } + + return weather +} |
