184 lines
4.7 KiB
Go
184 lines
4.7 KiB
Go
package geo
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/leaktechnologies/skyfeed/internal/config"
|
|
)
|
|
|
|
// Station represents an Environment Canada citypage station.
|
|
type Station struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
Province string `json:"province"`
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}
|
|
|
|
// stationCacheFile defines where we persist the station index locally.
|
|
const stationCacheFile = "stations.json"
|
|
|
|
// FindNearestStation locates the closest Environment Canada weather station to given coordinates.
|
|
func FindNearestStation(lat, lon float64) (Station, error) {
|
|
if lat == 0 && lon == 0 {
|
|
return Station{}, errors.New("invalid coordinates: cannot locate nearest station")
|
|
}
|
|
|
|
stations, err := ensureStationCache()
|
|
if err != nil {
|
|
return Station{}, fmt.Errorf("failed to load station list: %w", err)
|
|
}
|
|
|
|
var nearest Station
|
|
minDist := math.MaxFloat64
|
|
|
|
for _, s := range stations {
|
|
d := Haversine(lat, lon, s.Lat, s.Lon)
|
|
if d < minDist {
|
|
minDist = d
|
|
nearest = s
|
|
}
|
|
}
|
|
|
|
if nearest.Code == "" {
|
|
return Station{}, errors.New("no station found in index")
|
|
}
|
|
|
|
fmt.Printf("[geo] Nearest station: %s (%s, %.2f km)\n", nearest.Name, nearest.Province, minDist)
|
|
return nearest, nil
|
|
}
|
|
|
|
// ensureStationCache loads the cached station list or updates it if missing/outdated.
|
|
func ensureStationCache() ([]Station, error) {
|
|
cachePath := filepath.Join(config.DataDir, stationCacheFile)
|
|
|
|
info, err := os.Stat(cachePath)
|
|
if os.IsNotExist(err) {
|
|
fmt.Println("[geo] No station cache found, fetching from Environment Canada...")
|
|
return updateStationCache(cachePath)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Refresh monthly
|
|
modTime := info.ModTime().UTC()
|
|
now := time.Now().UTC()
|
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
if modTime.Before(firstOfMonth) {
|
|
fmt.Println("[geo] Station cache is older than this month, refreshing...")
|
|
return updateStationCache(cachePath)
|
|
}
|
|
|
|
// Load existing JSON cache
|
|
f, err := os.Open(cachePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var stations []Station
|
|
if err := json.NewDecoder(f).Decode(&stations); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return stations, nil
|
|
}
|
|
|
|
// updateStationCache fetches and parses Environment Canada's current site list (XML).
|
|
func updateStationCache(dest string) ([]Station, error) {
|
|
const ecURL = "https://geo.weather.gc.ca/geomet/features/collections/citypage_weather:siteList/items?f=xml"
|
|
|
|
fmt.Println("[geo] Downloading station list from:", ecURL)
|
|
|
|
client := &http.Client{Timeout: 25 * time.Second}
|
|
resp, err := client.Get(ecURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch EC station list: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("unexpected HTTP status: %s — %s", resp.Status, string(body))
|
|
}
|
|
|
|
type Site struct {
|
|
Code string `xml:"properties>code"`
|
|
NameEn string `xml:"properties>nameEn"`
|
|
NameFr string `xml:"properties>nameFr"`
|
|
Province string `xml:"properties>provinceCode"`
|
|
Lat float64 `xml:"geometry>coordinates>1"` // Note: GeoJSON order is [lon, lat]
|
|
Lon float64 `xml:"geometry>coordinates>0"`
|
|
}
|
|
|
|
var parsed struct {
|
|
Sites []Site `xml:"member"`
|
|
}
|
|
|
|
if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return nil, fmt.Errorf("failed to parse site list XML: %w", err)
|
|
}
|
|
|
|
stations := make([]Station, 0, len(parsed.Sites))
|
|
for _, s := range parsed.Sites {
|
|
if s.Code == "" || s.Lat == 0 || s.Lon == 0 {
|
|
continue
|
|
}
|
|
name := s.NameEn
|
|
if name == "" {
|
|
name = s.NameFr
|
|
}
|
|
stations = append(stations, Station{
|
|
Name: name,
|
|
Code: s.Code,
|
|
Province: s.Province,
|
|
Lat: s.Lat,
|
|
Lon: s.Lon,
|
|
})
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
enc := json.NewEncoder(f)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(stations); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Printf("[geo] Saved %d Environment Canada stations → %s\n", len(stations), dest)
|
|
return stations, nil
|
|
}
|
|
|
|
// Haversine computes the great-circle distance (in km) between two coordinates.
|
|
func Haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
|
const R = 6371
|
|
dLat := toRadians(lat2 - lat1)
|
|
dLon := toRadians(lon2 - lon1)
|
|
lat1R := toRadians(lat1)
|
|
lat2R := toRadians(lat2)
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Cos(lat1R)*math.Cos(lat2R)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
return R * c
|
|
}
|
|
|
|
func toRadians(deg float64) float64 { return deg * math.Pi / 180 }
|