feat: polish & windviz & deploy

This commit is contained in:
Anatoly Antonov 2026-05-30 06:29:39 +09:00
parent 81b8e763bd
commit 465ad00f7b
78 changed files with 20622 additions and 2154 deletions

View file

@ -1,10 +1,18 @@
// 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.
// Command compare-tawhiri runs identical predictions against a local predictor
// and a hosted Tawhiri instance and reports how closely they agree.
//
// Intended use:
// To make the comparison test the engine rather than data drift, it discovers
// the local predictor's loaded GFS run via /ready and asks Tawhiri to use the
// same run (the `dataset` parameter), so both integrate identical wind data.
// It compares the burst apex (terrain-independent) and the landing point
// (terrain-dependent) separately, since without the ruaumoko elevation dataset
// the local predictor terminates descent at sea level while Tawhiri uses
// ground elevation.
//
// ./compare-tawhiri --server http://localhost:8080
// Usage:
//
// compare-tawhiri --server http://localhost:8080 # built-in suite
// compare-tawhiri --lat 52.2 --lng 0.1 --burst 30000 # single site
package main
import (
@ -16,66 +24,215 @@ import (
"net/http"
"net/url"
"os"
"text/tabwriter"
"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")
var (
server = flag.String("server", "http://localhost:8080", "local predictor base URL")
tawhiri = flag.String("tawhiri", "https://api.v2.sondehub.org/tawhiri", "hosted Tawhiri base URL")
lat = flag.Float64("lat", math.NaN(), "launch latitude (single-site mode)")
lng = flag.Float64("lng", math.NaN(), "launch longitude (single-site mode)")
alt = flag.Float64("alt", 0, "launch altitude m")
ascent = 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: epoch + 3h)")
align = flag.Bool("align-dataset", true, "ask Tawhiri to use the local predictor's GFS run")
)
flag.Parse()
// Discover the active dataset epoch from /ready.
epoch, err := fetchActiveEpoch(*server)
if err != nil {
fmt.Fprintln(os.Stderr, "ready:", err)
fmt.Fprintln(os.Stderr, "local /ready:", err)
os.Exit(1)
}
fmt.Printf("local dataset epoch: %s\n", epoch.Format(time.RFC3339))
launchTime := epoch.Add(3 * time.Hour)
if *launch != "" {
t, err := time.Parse(time.RFC3339, *launch)
launchTime, err = time.Parse(time.RFC3339, *launch)
if err != nil {
fmt.Fprintln(os.Stderr, "invalid launch time:", err)
fmt.Fprintln(os.Stderr, "invalid --launch:", err)
os.Exit(1)
}
launchTime = t
}
datasetParam := ""
if *align {
datasetParam = epoch.Format(time.RFC3339)
}
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)
sites := suite()
if !math.IsNaN(*lat) && !math.IsNaN(*lng) {
sites = []site{{name: "custom", lat: *lat, lng: *lng}}
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "\nsite\tburst Δ\tlanding Δ\tapex alt Δ\tland alt Δ\tasc pts\tdesc pts\tnotes")
fmt.Fprintln(tw, "----\t-------\t---------\t----------\t----------\t-------\t--------\t-----")
var worst float64
compared := 0
for _, s := range sites {
p := params{lat: s.lat, lng: s.lng, alt: *alt, launch: launchTime,
ascent: *ascent, burst: *burst, descent: *descent}
ours, err := predict(*server+"/api/v1/prediction", p, "")
if err != nil {
fmt.Fprintf(tw, "%s\tlocal error: %v\n", s.name, err)
continue
}
theirs, err := predict(*tawhiri, p, datasetParam)
if err != nil {
fmt.Fprintf(tw, "%s\ttawhiri error: %v\n", s.name, err)
continue
}
compared++
burstD := haversine(ours.apexLat, ours.apexLng, theirs.apexLat, theirs.apexLng)
landD := haversine(ours.landLat, ours.landLng, theirs.landLat, theirs.landLng)
if landD > worst {
worst = landD
}
note := ""
if theirs.dataset != "" && ours.dataset != "" && theirs.dataset != ours.dataset {
note = fmt.Sprintf("dataset mismatch (theirs=%s)", theirs.dataset)
}
fmt.Fprintf(tw, "%s\t%.0f m\t%.2f km\t%.0f m\t%.0f m\t%d/%d\t%d/%d\t%s\n",
s.name, burstD, landD/1000,
math.Abs(ours.apexAlt-theirs.apexAlt), math.Abs(ours.landAlt-theirs.landAlt),
ours.ascPts, theirs.ascPts, ours.descPts, theirs.descPts, note)
}
tw.Flush()
if compared == 0 {
fmt.Println("\nVERDICT: NO COMPARISONS (every site errored — see rows above)")
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)
fmt.Printf("\ncompared %d/%d sites; worst landing distance: %.2f km\n", compared, len(sites), worst/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)
case worst < 1000:
fmt.Println("VERDICT: MATCH (all landings < 1 km — engine agrees with Tawhiri)")
case worst < 50000:
fmt.Println("VERDICT: CLOSE (< 50 km — consistent with elevation/dataset differences)")
default:
fmt.Printf("LARGE (%.1f km) — investigate\n", d/1000)
fmt.Println("VERDICT: DIVERGENT (> 50 km — investigate)")
os.Exit(2)
}
}
type site struct {
name string
lat, lng float64
}
// suite is a small set of diverse launch points: UK (lands on land/sea
// depending on winds), mid-Atlantic and mid-Pacific (ocean landings, so the
// sea-level-vs-terrain difference vanishes), and southern hemisphere.
func suite() []site {
return []site{
{"cambridge-uk", 52.2135, 0.0964},
{"mid-atlantic", 35.0, -40.0},
{"mid-pacific", 0.0, -160.0},
{"new-zealand", -41.3, 174.8},
{"colorado-us", 39.0, -105.5},
}
}
type params struct {
lat, lng, alt float64
launch time.Time
ascent, burst, descent float64
}
type result struct {
apexLat, apexLng, apexAlt float64
landLat, landLng, landAlt float64
ascPts, descPts int
dataset string
}
func predict(endpoint string, p params, dataset string) (result, error) {
// Tawhiri requires longitude in [0, 360); normalize so both endpoints get
// the same request. Returned trajectory longitudes are [-180, 180] on both
// sides, so the comparison stays consistent.
lng := p.lng
if lng < 0 {
lng += 360
}
q := url.Values{}
q.Set("launch_latitude", fmt.Sprintf("%.4f", p.lat))
q.Set("launch_longitude", fmt.Sprintf("%.4f", lng))
q.Set("launch_altitude", fmt.Sprintf("%.0f", p.alt))
q.Set("launch_datetime", p.launch.Format(time.RFC3339))
q.Set("ascent_rate", fmt.Sprintf("%.2f", p.ascent))
q.Set("burst_altitude", fmt.Sprintf("%.0f", p.burst))
q.Set("descent_rate", fmt.Sprintf("%.2f", p.descent))
if dataset != "" {
q.Set("dataset", dataset)
}
full := endpoint + "?" + q.Encode()
var body []byte
var status int
var lastErr error
for range 3 {
resp, err := http.Get(full)
if err != nil {
lastErr = err
time.Sleep(time.Second)
continue
}
body, _ = io.ReadAll(resp.Body)
status = resp.StatusCode
resp.Body.Close()
lastErr = nil
break
}
if lastErr != nil {
return result{}, lastErr
}
if status != 200 {
return result{}, fmt.Errorf("HTTP %d: %s", status, truncate(string(body), 160))
}
var doc 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"`
Request struct {
Dataset string `json:"dataset"`
} `json:"request"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return result{}, err
}
var r result
r.dataset = doc.Request.Dataset
for _, st := range doc.Prediction {
if len(st.Trajectory) == 0 {
continue
}
last := st.Trajectory[len(st.Trajectory)-1]
switch st.Stage {
case "ascent":
r.ascPts = len(st.Trajectory)
r.apexLat, r.apexLng, r.apexAlt = last.Latitude, last.Longitude, last.Altitude
case "descent":
r.descPts = len(st.Trajectory)
r.landLat, r.landLng, r.landAlt = last.Latitude, last.Longitude, last.Altitude
}
}
return r, nil
}
type readinessResp struct {
Status string `json:"status"`
DatasetTime string `json:"dataset_time"`
@ -96,52 +253,11 @@ func fetchActiveEpoch(base string) (time.Time, error) {
return time.Time{}, err
}
if r.Status != "ok" {
return time.Time{}, fmt.Errorf("server status %q", r.Status)
return time.Time{}, fmt.Errorf("server status %q (no dataset loaded yet)", 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
@ -151,3 +267,10 @@ func haversine(lat1, lng1, lat2, lng2 float64) float64 {
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))
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View file

@ -213,4 +213,3 @@ func printResp(resp *http.Response) error {
}
return nil
}

View file

@ -19,7 +19,6 @@ import (
"go.uber.org/zap/zapcore"
"predictor-refactored/internal/api"
"predictor-refactored/internal/api/async"
"predictor-refactored/internal/config"
"predictor-refactored/internal/datasets"
"predictor-refactored/internal/datasets/gefs"
@ -27,15 +26,65 @@ import (
"predictor-refactored/internal/elevation"
"predictor-refactored/internal/metrics"
wgfs "predictor-refactored/internal/weather/gfs"
"predictor-refactored/internal/windviz"
)
// Build metadata, injected via -ldflags at build time (see Dockerfile).
var (
version = "dev"
revision = "unknown"
)
func main() {
// `predictor -healthcheck` probes the local /health endpoint and exits
// 0/1. The container HEALTHCHECK uses it so the (distroless) image needs
// no shell or curl.
for _, a := range os.Args[1:] {
if a == "-healthcheck" || a == "--healthcheck" {
os.Exit(healthcheck())
}
}
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
// healthcheck performs a liveness probe against the local server. It resolves
// the port through the same config loader as the server, so the probe always
// matches the bind port regardless of how it was set (flag, env, or file).
func healthcheck() int {
port := 8080
if cfg, err := config.Load(withoutHealthcheckFlag(os.Args[1:])); err == nil {
port = cfg.HTTP.Port
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/health", port))
if err != nil {
fmt.Fprintln(os.Stderr, "healthcheck:", err)
return 1
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintln(os.Stderr, "healthcheck: status", resp.StatusCode)
return 1
}
return 0
}
// withoutHealthcheckFlag drops the -healthcheck flag so the remaining args
// parse cleanly through config.Load (which does not define it).
func withoutHealthcheckFlag(args []string) []string {
out := make([]string, 0, len(args))
for _, a := range args {
if a == "-healthcheck" || a == "--healthcheck" {
continue
}
out = append(out, a)
}
return out
}
func run(args []string) error {
cfg, err := config.Load(args)
if err != nil {
@ -48,6 +97,10 @@ func run(args []string) error {
}
defer log.Sync()
log.Info("starting stratoflights-predictor",
zap.String("version", version),
zap.String("revision", revision))
log.Info("configuration loaded",
zap.Int("port", cfg.HTTP.Port),
zap.String("data_dir", cfg.Data.Dir),
@ -141,12 +194,10 @@ func run(args []string) error {
scheduler.StartAsync()
defer scheduler.Stop()
asyncMgr := async.New(async.Config{
Workers: cfg.HTTP.AsyncWorkers,
QueueSize: cfg.HTTP.AsyncQueueSize,
ResultTTL: cfg.HTTP.AsyncResultTTL,
}, mgr, elev, sink, log)
defer asyncMgr.Close()
var windCache *windviz.Cache
if cfg.Wind.Enabled {
windCache = windviz.NewCache(cfg.Wind.CacheSize, cfg.Wind.CacheTTL)
}
server, err := api.New(cfg.HTTP.Port, api.Deps{
Manager: mgr,
@ -154,12 +205,17 @@ func run(args []string) error {
Metrics: sink,
MetricsHandler: metricsHandler,
MetricsPath: cfg.Metrics.Path,
AsyncManager: asyncMgr,
EnableWind: cfg.Wind.Enabled,
WindCache: windCache,
AsyncWorkers: cfg.HTTP.AsyncWorkers,
AsyncQueueSize: cfg.HTTP.AsyncQueueSize,
AsyncResultTTL: cfg.HTTP.AsyncResultTTL,
Log: log,
})
if err != nil {
return fmt.Errorf("init server: %w", err)
}
defer server.Close()
// Graceful shutdown
ctx, cancel := signalContext()