step one
This commit is contained in:
parent
7a8d5d13fa
commit
9e663db9dc
68 changed files with 5647 additions and 2958 deletions
|
|
@ -1,98 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/downloader"
|
||||
"predictor-refactored/internal/service"
|
||||
"predictor-refactored/internal/transport/rest"
|
||||
"predictor-refactored/internal/transport/rest/handler"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer log.Sync()
|
||||
|
||||
cfg := downloader.LoadConfig()
|
||||
log.Info("configuration loaded",
|
||||
zap.String("data_dir", cfg.DataDir),
|
||||
zap.Int("parallel", cfg.Parallel),
|
||||
zap.Duration("update_interval", cfg.UpdateInterval),
|
||||
zap.Duration("dataset_ttl", cfg.DatasetTTL))
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
log.Fatal("failed to create data directory", zap.Error(err))
|
||||
}
|
||||
|
||||
svc := service.New(cfg, log)
|
||||
defer svc.Close()
|
||||
|
||||
// Load elevation dataset (optional — falls back to sea-level termination)
|
||||
elevPath := "/srv/ruaumoko-dataset"
|
||||
if v := os.Getenv("PREDICTOR_ELEVATION_DATASET"); v != "" {
|
||||
elevPath = v
|
||||
}
|
||||
svc.LoadElevation(elevPath)
|
||||
|
||||
// Initial dataset load (async so the server starts immediately)
|
||||
go func() {
|
||||
log.Info("performing initial dataset update...")
|
||||
if err := svc.Update(context.Background()); err != nil {
|
||||
log.Error("initial dataset update failed", zap.Error(err))
|
||||
} else {
|
||||
log.Info("initial dataset update complete")
|
||||
}
|
||||
}()
|
||||
|
||||
// Scheduler for periodic dataset updates
|
||||
scheduler := gocron.NewScheduler(time.UTC)
|
||||
scheduler.Every(cfg.UpdateInterval).Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
log.Info("scheduled dataset update starting")
|
||||
if err := svc.Update(ctx); err != nil {
|
||||
log.Error("scheduled dataset update failed", zap.Error(err))
|
||||
} else {
|
||||
log.Info("scheduled dataset update complete")
|
||||
}
|
||||
})
|
||||
scheduler.StartAsync()
|
||||
defer scheduler.Stop()
|
||||
|
||||
// HTTP transport (ogen)
|
||||
port := 8080
|
||||
if p := os.Getenv("PREDICTOR_PORT"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &port)
|
||||
}
|
||||
|
||||
h := handler.New(svc, log)
|
||||
transport, err := rest.New(h, port, log)
|
||||
if err != nil {
|
||||
log.Fatal("failed to create transport", zap.Error(err))
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := transport.Run(); err != nil {
|
||||
log.Fatal("HTTP server error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("service started")
|
||||
|
||||
// Graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-sigChan
|
||||
log.Info("received shutdown signal", zap.String("signal", sig.String()))
|
||||
}
|
||||
153
cmd/compare-tawhiri/main.go
Normal file
153
cmd/compare-tawhiri/main.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Command compare-tawhiri runs the same prediction against a local predictor
|
||||
// instance and against the public SondeHub Tawhiri instance, reporting the
|
||||
// distance between the two predicted landing points.
|
||||
//
|
||||
// Intended use:
|
||||
//
|
||||
// ./compare-tawhiri --server http://localhost:8080
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tawhiriPublicURL = "https://api.v2.sondehub.org/tawhiri"
|
||||
|
||||
func main() {
|
||||
server := flag.String("server", "http://localhost:8080", "local predictor server URL")
|
||||
lat := flag.Float64("lat", 52.2135, "launch latitude")
|
||||
lng := flag.Float64("lng", 0.0964, "launch longitude")
|
||||
alt := flag.Float64("alt", 0, "launch altitude")
|
||||
rate := flag.Float64("ascent-rate", 5, "ascent rate m/s")
|
||||
burst := flag.Float64("burst", 30000, "burst altitude m")
|
||||
descent := flag.Float64("descent-rate", 5, "descent rate m/s")
|
||||
launch := flag.String("launch", "", "launch time RFC3339; default: 3 hours after the active dataset epoch")
|
||||
flag.Parse()
|
||||
|
||||
// Discover the active dataset epoch from /ready.
|
||||
epoch, err := fetchActiveEpoch(*server)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "ready:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
launchTime := epoch.Add(3 * time.Hour)
|
||||
if *launch != "" {
|
||||
t, err := time.Parse(time.RFC3339, *launch)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "invalid launch time:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
launchTime = t
|
||||
}
|
||||
|
||||
ourLat, ourLng, err := runPrediction(*server+"/api/v1/prediction", *lat, *lng, *alt, launchTime, *rate, *burst, *descent)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "local prediction:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("local landing: lat=%.4f, lng=%.4f\n", ourLat, ourLng)
|
||||
|
||||
tawLat, tawLng, err := runPrediction(tawhiriPublicURL, *lat, *lng, *alt, launchTime, *rate, *burst, *descent)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tawhiri prediction:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("tawhiri landing: lat=%.4f, lng=%.4f\n", tawLat, tawLng)
|
||||
|
||||
d := haversine(ourLat, ourLng, tawLat, tawLng)
|
||||
fmt.Printf("distance: %.2f km\n", d/1000)
|
||||
|
||||
switch {
|
||||
case d < 1000:
|
||||
fmt.Println("MATCH (< 1 km)")
|
||||
case d < 50000:
|
||||
fmt.Printf("MODERATE (%.1f km) — likely different forecast runs\n", d/1000)
|
||||
default:
|
||||
fmt.Printf("LARGE (%.1f km) — investigate\n", d/1000)
|
||||
}
|
||||
}
|
||||
|
||||
type readinessResp struct {
|
||||
Status string `json:"status"`
|
||||
DatasetTime string `json:"dataset_time"`
|
||||
}
|
||||
|
||||
func fetchActiveEpoch(base string) (time.Time, error) {
|
||||
resp, err := http.Get(base + "/ready")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return time.Time{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var r readinessResp
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if r.Status != "ok" {
|
||||
return time.Time{}, fmt.Errorf("server status %q", r.Status)
|
||||
}
|
||||
return time.Parse(time.RFC3339, r.DatasetTime)
|
||||
}
|
||||
|
||||
func runPrediction(endpoint string, lat, lng, alt float64, launch time.Time, rate, burst, descent float64) (float64, float64, error) {
|
||||
q := url.Values{}
|
||||
q.Set("launch_latitude", fmt.Sprintf("%.4f", lat))
|
||||
q.Set("launch_longitude", fmt.Sprintf("%.4f", lng))
|
||||
q.Set("launch_altitude", fmt.Sprintf("%.0f", alt))
|
||||
q.Set("launch_datetime", launch.Format(time.RFC3339))
|
||||
q.Set("ascent_rate", fmt.Sprintf("%.1f", rate))
|
||||
q.Set("burst_altitude", fmt.Sprintf("%.0f", burst))
|
||||
q.Set("descent_rate", fmt.Sprintf("%.1f", descent))
|
||||
|
||||
resp, err := http.Get(endpoint + "?" + q.Encode())
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Prediction []struct {
|
||||
Stage string `json:"stage"`
|
||||
Trajectory []struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
} `json:"trajectory"`
|
||||
} `json:"prediction"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for _, stage := range result.Prediction {
|
||||
if stage.Stage == "descent" && len(stage.Trajectory) > 0 {
|
||||
last := stage.Trajectory[len(stage.Trajectory)-1]
|
||||
return last.Latitude, last.Longitude, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, fmt.Errorf("no descent stage in response")
|
||||
}
|
||||
|
||||
func haversine(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
const R = 6371000.0
|
||||
phi1 := lat1 * math.Pi / 180
|
||||
phi2 := lat2 * math.Pi / 180
|
||||
dphi := (lat2 - lat1) * math.Pi / 180
|
||||
dlam := (lng2 - lng1) * math.Pi / 180
|
||||
a := math.Sin(dphi/2)*math.Sin(dphi/2) + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dlam/2)*math.Sin(dlam/2)
|
||||
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/dataset"
|
||||
"predictor-refactored/internal/downloader"
|
||||
"predictor-refactored/internal/prediction"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Downloads a few forecast steps and runs a prediction, then compares
|
||||
// against the public Tawhiri API.
|
||||
func main() {
|
||||
log, _ := zap.NewDevelopment()
|
||||
|
||||
cfg := &downloader.Config{
|
||||
DataDir: os.TempDir(),
|
||||
Parallel: 4,
|
||||
}
|
||||
dl := downloader.NewDownloader(cfg, log)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Find latest run
|
||||
run, err := dl.FindLatestRun(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FindLatestRun: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Using run: %s\n", run.Format("2006010215"))
|
||||
|
||||
// Create dataset and download first 10 steps (0-27 hours, enough for a prediction)
|
||||
dsPath := fmt.Sprintf("/tmp/pred_test_%s.bin", run.Format("2006010215"))
|
||||
defer os.Remove(dsPath)
|
||||
|
||||
ds, err := dataset.Create(dsPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Create: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
date := run.Format("20060102")
|
||||
runHour := run.Hour()
|
||||
stepsToDownload := []int{0, 3, 6, 9, 12, 15, 18, 21, 24, 27}
|
||||
|
||||
fmt.Printf("Downloading %d steps...\n", len(stepsToDownload))
|
||||
for _, step := range stepsToDownload {
|
||||
hourIdx := dataset.HourIndex(step)
|
||||
fmt.Printf(" step %d (hour idx %d)...\n", step, hourIdx)
|
||||
|
||||
urlA := dataset.GribURL(date, runHour, step)
|
||||
if err := dl.DownloadAndBlit(ctx, ds, urlA, hourIdx, dataset.LevelSetA); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " pgrb2 step %d: %v\n", step, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
urlB := dataset.GribURLB(date, runHour, step)
|
||||
if err := dl.DownloadAndBlit(ctx, ds, urlB, hourIdx, dataset.LevelSetB); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " pgrb2b step %d: %v\n", step, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
ds.Flush()
|
||||
fmt.Println("Download complete")
|
||||
|
||||
// Set dataset time
|
||||
ds.DSTime = run
|
||||
|
||||
// Run our prediction
|
||||
launchLat := 52.2135
|
||||
launchLon := 0.0964 // already in [0, 360)
|
||||
launchAlt := 0.0
|
||||
ascentRate := 5.0
|
||||
burstAlt := 30000.0
|
||||
descentRate := 5.0
|
||||
|
||||
// Launch 3 hours into the forecast
|
||||
launchTime := run.Add(3 * time.Hour)
|
||||
launchTimestamp := float64(launchTime.Unix())
|
||||
dsEpoch := float64(run.Unix())
|
||||
|
||||
warnings := &prediction.Warnings{}
|
||||
stages := prediction.StandardProfile(ascentRate, burstAlt, descentRate, ds, dsEpoch, warnings, nil)
|
||||
results := prediction.RunPrediction(launchTimestamp, launchLat, launchLon, launchAlt, stages)
|
||||
|
||||
fmt.Printf("\n=== Our prediction ===\n")
|
||||
for i, sr := range results {
|
||||
stage := "ascent"
|
||||
if i == 1 {
|
||||
stage = "descent"
|
||||
}
|
||||
first := sr.Points[0]
|
||||
last := sr.Points[len(sr.Points)-1]
|
||||
fmt.Printf(" %s: %d points, start=(%.4f, %.4f, %.0fm) end=(%.4f, %.4f, %.0fm)\n",
|
||||
stage, len(sr.Points),
|
||||
first.Lat, first.Lng, first.Alt,
|
||||
last.Lat, last.Lng, last.Alt)
|
||||
}
|
||||
|
||||
// Get landing point
|
||||
var ourLandLat, ourLandLon float64
|
||||
if len(results) >= 2 {
|
||||
last := results[1].Points[len(results[1].Points)-1]
|
||||
ourLandLat = last.Lat
|
||||
ourLandLon = last.Lng
|
||||
if ourLandLon > 180 {
|
||||
ourLandLon -= 360
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Landing: lat=%.4f, lon=%.4f\n", ourLandLat, ourLandLon)
|
||||
|
||||
// Compare against public Tawhiri API
|
||||
fmt.Printf("\n=== Tawhiri API comparison ===\n")
|
||||
tawhiriLandLat, tawhiriLandLon, err := queryTawhiri(launchLat, launchLon, launchAlt, launchTime, ascentRate, burstAlt, descentRate)
|
||||
if err != nil {
|
||||
fmt.Printf(" Tawhiri API error: %v\n", err)
|
||||
fmt.Println(" (Cannot compare — Tawhiri may use a different dataset)")
|
||||
ds.Close()
|
||||
return
|
||||
}
|
||||
fmt.Printf(" Tawhiri landing: lat=%.4f, lon=%.4f\n", tawhiriLandLat, tawhiriLandLon)
|
||||
|
||||
dist := haversine(ourLandLat, ourLandLon, tawhiriLandLat, tawhiriLandLon)
|
||||
fmt.Printf(" Distance between landing points: %.2f km\n", dist/1000)
|
||||
|
||||
if dist < 1000 {
|
||||
fmt.Println(" CLOSE MATCH (< 1 km)")
|
||||
} else if dist < 50000 {
|
||||
fmt.Printf(" MODERATE DIFFERENCE (%.1f km) — likely different datasets\n", dist/1000)
|
||||
} else {
|
||||
fmt.Printf(" LARGE DIFFERENCE (%.1f km) — possible bug\n", dist/1000)
|
||||
}
|
||||
|
||||
ds.Close()
|
||||
}
|
||||
|
||||
func queryTawhiri(lat, lon, alt float64, launchTime time.Time, ascentRate, burstAlt, descentRate float64) (landLat, landLon float64, err error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.v2.sondehub.org/tawhiri?launch_latitude=%.4f&launch_longitude=%.4f&launch_altitude=%.0f&launch_datetime=%s&ascent_rate=%.1f&burst_altitude=%.0f&descent_rate=%.1f",
|
||||
lat, lon, alt, launchTime.Format(time.RFC3339), ascentRate, burstAlt, descentRate)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Prediction []struct {
|
||||
Stage string `json:"stage"`
|
||||
Trajectory []struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
} `json:"trajectory"`
|
||||
} `json:"prediction"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, stage := range result.Prediction {
|
||||
if stage.Stage == "descent" && len(stage.Trajectory) > 0 {
|
||||
last := stage.Trajectory[len(stage.Trajectory)-1]
|
||||
return last.Latitude, last.Longitude, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0, fmt.Errorf("no descent stage found")
|
||||
}
|
||||
|
||||
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const R = 6371000.0
|
||||
phi1 := lat1 * math.Pi / 180
|
||||
phi2 := lat2 * math.Pi / 180
|
||||
dphi := (lat2 - lat1) * math.Pi / 180
|
||||
dlam := (lon2 - lon1) * math.Pi / 180
|
||||
a := math.Sin(dphi/2)*math.Sin(dphi/2) + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dlam/2)*math.Sin(dlam/2)
|
||||
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/dataset"
|
||||
"predictor-refactored/internal/downloader"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Downloads step 0 of a given run and writes a minimal dataset for comparison.
|
||||
// Usage: go run ./cmd/compare_step0 <run_YYYYMMDDHH> <output_path>
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s <run_YYYYMMDDHH> <output_path>\n", os.Args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
runStr := os.Args[1]
|
||||
outPath := os.Args[2]
|
||||
|
||||
run, err := time.Parse("2006010215", runStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Invalid run time %q: %v\n", runStr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log, _ := zap.NewDevelopment()
|
||||
|
||||
// Create a full-size dataset (we only fill step 0)
|
||||
fmt.Printf("Creating dataset at %s (%d bytes)...\n", outPath, dataset.DatasetSize)
|
||||
ds, err := dataset.Create(outPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Create dataset: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ds.Close()
|
||||
|
||||
cfg := &downloader.Config{
|
||||
DataDir: os.TempDir(),
|
||||
Parallel: 4,
|
||||
}
|
||||
dl := downloader.NewDownloader(cfg, log)
|
||||
|
||||
ctx := context.Background()
|
||||
date := run.Format("20060102")
|
||||
runHour := run.Hour()
|
||||
|
||||
// Download and blit step 0 from pgrb2
|
||||
fmt.Println("Downloading pgrb2 step 0...")
|
||||
urlA := dataset.GribURL(date, runHour, 0)
|
||||
if err := dl.DownloadAndBlit(ctx, ds, urlA, 0, dataset.LevelSetA); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pgrb2: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" done")
|
||||
|
||||
// Download and blit step 0 from pgrb2b
|
||||
fmt.Println("Downloading pgrb2b step 0...")
|
||||
urlB := dataset.GribURLB(date, runHour, 0)
|
||||
if err := dl.DownloadAndBlit(ctx, ds, urlB, 0, dataset.LevelSetB); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pgrb2b: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" done")
|
||||
|
||||
if err := ds.Flush(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Flush: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Spot-check: print same values as the Python script
|
||||
fmt.Println("\n=== Go dataset values (spot check) ===")
|
||||
type testPoint struct {
|
||||
varName string
|
||||
varIdx int
|
||||
levelIdx int
|
||||
lat, lon int
|
||||
}
|
||||
|
||||
points := []testPoint{
|
||||
{"HGT", 0, 0, 0, 0}, // HGT @ 1000mb, lat=-90, lon=0
|
||||
{"HGT", 0, 0, 180, 0}, // HGT @ 1000mb, lat=0, lon=0
|
||||
{"HGT", 0, 0, 360, 0}, // HGT @ 1000mb, lat=+90, lon=0
|
||||
{"HGT", 0, 20, 180, 360}, // HGT @ 500mb, lat=0, lon=180
|
||||
{"UGRD", 1, 0, 180, 0}, // UGRD @ 1000mb, lat=0, lon=0
|
||||
{"VGRD", 2, 0, 180, 0}, // VGRD @ 1000mb, lat=0, lon=0
|
||||
{"UGRD", 1, 20, 284, 0}, // UGRD @ 500mb, lat=52N, lon=0
|
||||
}
|
||||
|
||||
for _, p := range points {
|
||||
val := ds.Val(0, p.levelIdx, p.varIdx, p.lat, p.lon)
|
||||
actualLat := -90.0 + float64(p.lat)*0.5
|
||||
actualLon := float64(p.lon) * 0.5
|
||||
fmt.Printf(" %-4s %4dmb lat=%+7.1f lon=%6.1f: %12.4f\n",
|
||||
p.varName, dataset.Pressures[p.levelIdx], actualLat, actualLon, val)
|
||||
}
|
||||
|
||||
fmt.Printf("\nDataset written to %s\n", outPath)
|
||||
}
|
||||
216
cmd/predictor-cli/main.go
Normal file
216
cmd/predictor-cli/main.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Command predictor-cli is a small HTTP client for stratoflights-predictor.
|
||||
//
|
||||
// It is intended for operations and development; production callers should
|
||||
// use the REST API directly.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const usage = `predictor-cli — HTTP client for stratoflights-predictor
|
||||
|
||||
USAGE
|
||||
predictor-cli [--server URL] <command> [args...]
|
||||
|
||||
COMMANDS
|
||||
ready Check service health
|
||||
predict <KEY=VAL>... Run a Tawhiri-compat prediction (key=value pairs)
|
||||
datasets list List stored dataset epochs
|
||||
datasets download [--latest|--epoch RFC3339]
|
||||
Trigger a dataset download
|
||||
datasets delete <epoch> Delete a stored dataset
|
||||
jobs list List download jobs
|
||||
jobs get <id> Show one job
|
||||
jobs cancel <id> Cancel a running job
|
||||
|
||||
ENVIRONMENT
|
||||
PREDICTOR_SERVER Default --server (overridden by the flag)
|
||||
`
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("predictor-cli", flag.ContinueOnError)
|
||||
fs.Usage = func() { fmt.Fprint(os.Stderr, usage) }
|
||||
server := fs.String("server", envDefault("PREDICTOR_SERVER", "http://localhost:8080"), "predictor server URL")
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
c := &client{base: strings.TrimRight(*server, "/"), http: &http.Client{Timeout: 30 * time.Second}}
|
||||
if err := dispatch(c, args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func envDefault(name, fallback string) string {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func dispatch(c *client, args []string) error {
|
||||
switch args[0] {
|
||||
case "ready":
|
||||
return c.ready()
|
||||
case "predict":
|
||||
return c.predict(args[1:])
|
||||
case "datasets":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: datasets {list|download|delete}")
|
||||
}
|
||||
switch args[1] {
|
||||
case "list":
|
||||
return c.datasetsList()
|
||||
case "download":
|
||||
return c.datasetsDownload(args[2:])
|
||||
case "delete":
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("usage: datasets delete <epoch>")
|
||||
}
|
||||
return c.datasetsDelete(args[2])
|
||||
}
|
||||
case "jobs":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: jobs {list|get|cancel}")
|
||||
}
|
||||
switch args[1] {
|
||||
case "list":
|
||||
return c.jobsList()
|
||||
case "get":
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("usage: jobs get <id>")
|
||||
}
|
||||
return c.jobsGet(args[2])
|
||||
case "cancel":
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("usage: jobs cancel <id>")
|
||||
}
|
||||
return c.jobsCancel(args[2])
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
|
||||
type client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (c *client) ready() error {
|
||||
return c.getPrint("/ready")
|
||||
}
|
||||
|
||||
func (c *client) predict(kv []string) error {
|
||||
q := url.Values{}
|
||||
for _, p := range kv {
|
||||
idx := strings.IndexByte(p, '=')
|
||||
if idx <= 0 {
|
||||
return fmt.Errorf("expected key=value, got %q", p)
|
||||
}
|
||||
q.Set(p[:idx], p[idx+1:])
|
||||
}
|
||||
return c.getPrint("/api/v1/prediction?" + q.Encode())
|
||||
}
|
||||
|
||||
func (c *client) datasetsList() error {
|
||||
return c.getPrint("/api/v1/admin/datasets")
|
||||
}
|
||||
|
||||
func (c *client) datasetsDownload(args []string) error {
|
||||
fs := flag.NewFlagSet("datasets download", flag.ContinueOnError)
|
||||
latest := fs.Bool("latest", false, "download the latest available run")
|
||||
epoch := fs.String("epoch", "", "RFC3339 epoch to download")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{}
|
||||
if *latest {
|
||||
body["latest"] = true
|
||||
}
|
||||
if *epoch != "" {
|
||||
body["epoch"] = *epoch
|
||||
}
|
||||
return c.postPrint("/api/v1/admin/datasets", body)
|
||||
}
|
||||
|
||||
func (c *client) datasetsDelete(epoch string) error {
|
||||
return c.deletePrint("/api/v1/admin/datasets/" + url.PathEscape(epoch))
|
||||
}
|
||||
|
||||
func (c *client) jobsList() error { return c.getPrint("/api/v1/admin/jobs") }
|
||||
func (c *client) jobsGet(id string) error {
|
||||
return c.getPrint("/api/v1/admin/jobs/" + url.PathEscape(id))
|
||||
}
|
||||
func (c *client) jobsCancel(id string) error {
|
||||
return c.deletePrint("/api/v1/admin/jobs/" + url.PathEscape(id))
|
||||
}
|
||||
|
||||
func (c *client) getPrint(path string) error {
|
||||
resp, err := c.http.Get(c.base + path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printResp(resp)
|
||||
}
|
||||
|
||||
func (c *client) postPrint(path string, body any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Post(c.base+path, "application/json", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printResp(resp)
|
||||
}
|
||||
|
||||
func (c *client) deletePrint(path string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printResp(resp)
|
||||
}
|
||||
|
||||
func printResp(resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
// Pretty-print JSON when possible; raw bytes otherwise.
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "json") && len(body) > 0 {
|
||||
var any any
|
||||
if err := json.Unmarshal(body, &any); err == nil {
|
||||
pretty, _ := json.MarshalIndent(any, "", " ")
|
||||
fmt.Println(string(pretty))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(body) > 0 {
|
||||
fmt.Println(strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
181
cmd/predictor/main.go
Normal file
181
cmd/predictor/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Command predictor is the stratoflights-predictor HTTP server.
|
||||
//
|
||||
// It wires the configuration, dataset manager, scheduler, and API layer
|
||||
// into a single process and exits cleanly on SIGINT/SIGTERM.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"predictor-refactored/internal/api"
|
||||
"predictor-refactored/internal/config"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/datasets/gfs"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/metrics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "fatal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfg, err := config.Load(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
log, err := newLogger(cfg.Log.Level)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init logger: %w", err)
|
||||
}
|
||||
defer log.Sync()
|
||||
|
||||
log.Info("configuration loaded",
|
||||
zap.Int("port", cfg.HTTP.Port),
|
||||
zap.String("data_dir", cfg.Data.Dir),
|
||||
zap.String("source", cfg.Data.Source),
|
||||
zap.Int("download_parallel", cfg.Download.Parallel),
|
||||
zap.Duration("update_interval", cfg.Download.UpdateInterval),
|
||||
zap.Duration("freshness_ttl", cfg.Download.FreshnessTTL),
|
||||
zap.Bool("metrics_enabled", cfg.Metrics.Enabled),
|
||||
)
|
||||
|
||||
store, err := datasets.NewLocalStore(cfg.Data.Dir, cfg.Data.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init store: %w", err)
|
||||
}
|
||||
|
||||
// Source is GFS today; the spec leaves room for ECMWF later via the
|
||||
// same datasets.Source interface.
|
||||
if cfg.Data.Source != "noaa-gfs-0p50" {
|
||||
return fmt.Errorf("source %q not supported", cfg.Data.Source)
|
||||
}
|
||||
source := gfs.NewSource(log)
|
||||
source.Parallel = cfg.Download.Parallel
|
||||
|
||||
var throttle datasets.Throttle
|
||||
if cfg.Download.BandwidthBytesPerSecond > 0 {
|
||||
throttle = datasets.NewTokenBucket(cfg.Download.BandwidthBytesPerSecond)
|
||||
}
|
||||
|
||||
// Metrics (optional).
|
||||
var sink metrics.Sink = metrics.Noop()
|
||||
var metricsHandler http.Handler
|
||||
if cfg.Metrics.Enabled {
|
||||
prom := metrics.NewProm()
|
||||
sink = prom
|
||||
metricsHandler = prom
|
||||
}
|
||||
|
||||
mgr := datasets.New(source, store, throttle, log)
|
||||
defer mgr.Close()
|
||||
|
||||
// Optional elevation dataset. Missing or unreadable elevation is logged
|
||||
// but non-fatal; descent terminates at sea level instead.
|
||||
var elev *elevation.Dataset
|
||||
if cfg.Data.ElevationPath != "" {
|
||||
if d, err := elevation.Open(cfg.Data.ElevationPath); err == nil {
|
||||
elev = d
|
||||
log.Info("elevation dataset loaded", zap.String("path", cfg.Data.ElevationPath))
|
||||
defer elev.Close()
|
||||
} else {
|
||||
log.Warn("elevation dataset not available, using sea-level termination",
|
||||
zap.String("path", cfg.Data.ElevationPath),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the initial refresh in the background so the server can start
|
||||
// answering /ready immediately.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil {
|
||||
log.Error("initial dataset refresh failed", zap.Error(err))
|
||||
}
|
||||
if a := mgr.Active(); a != nil {
|
||||
sink.ActiveEpoch(a.Epoch())
|
||||
}
|
||||
}()
|
||||
|
||||
scheduler := gocron.NewScheduler(time.UTC)
|
||||
scheduler.Every(cfg.Download.UpdateInterval).Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
log.Info("scheduled dataset refresh starting")
|
||||
if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil {
|
||||
log.Error("scheduled dataset refresh failed", zap.Error(err))
|
||||
}
|
||||
if a := mgr.Active(); a != nil {
|
||||
sink.ActiveEpoch(a.Epoch())
|
||||
}
|
||||
})
|
||||
scheduler.StartAsync()
|
||||
defer scheduler.Stop()
|
||||
|
||||
server, err := api.New(cfg.HTTP.Port, api.Deps{
|
||||
Manager: mgr,
|
||||
Elevation: elev,
|
||||
Metrics: sink,
|
||||
MetricsHandler: metricsHandler,
|
||||
MetricsPath: cfg.Metrics.Path,
|
||||
Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("init server: %w", err)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
|
||||
log.Info("service started")
|
||||
if err := server.Run(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
}
|
||||
log.Info("service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
func newLogger(level string) (*zap.Logger, error) {
|
||||
cfg := zap.NewProductionConfig()
|
||||
switch level {
|
||||
case "debug":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
|
||||
case "info":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
case "warn":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
|
||||
case "error":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.ErrorLevel)
|
||||
default:
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
return cfg.Build()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue