This repository has been archived on 2025-11-19. You can view files and clone it, but cannot push or open issues or pull requests.
Skyfeed_archive/internal/geo/stations.go

184 lines
4.7 KiB
Go

package geo
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"time"
"git.leaktechnologies.dev/Leak_Technologies/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 }