summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--calculator.go134
-rw-r--r--converter.go138
-rw-r--r--fmi_client.go88
-rw-r--r--forecast_test.go81
-rw-r--r--go.mod3
-rw-r--r--main.go401
-rw-r--r--models.go96
-rw-r--r--parser.go129
-rwxr-xr-xweatherbin0 -> 8840466 bytes
-rw-r--r--weather_mapper.go63
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)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..8b28fc6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module tammi.cc/weather
+
+go 1.25.5
diff --git a/main.go b/main.go
index b761844..c5008be 100644
--- a/main.go
+++ b/main.go
@@ -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&parameters=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
+}
diff --git a/weather b/weather
new file mode 100755
index 0000000..fceaa9d
--- /dev/null
+++ b/weather
Binary files differ
diff --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
+}