// 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)) }