feat: cleanup

This commit is contained in:
Anatoly Antonov 2026-03-28 00:38:16 +09:00
parent 8e9f117799
commit 82ef1cb3b8
66 changed files with 0 additions and 9521 deletions

View file

@ -1,114 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"math"
"os"
)
type Point struct {
Datetime string `json:"datetime"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude"`
}
type Stage struct {
Stage string `json:"stage"`
Trajectory []Point `json:"trajectory"`
}
type Prediction struct {
Prediction []Stage `json:"prediction"`
}
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
R := 6371000.0
phi1, phi2 := lat1*math.Pi/180, 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))
}
func load(path string) Prediction {
data, _ := os.ReadFile(path)
var p Prediction
json.Unmarshal(data, &p)
return p
}
func main() {
our := load("c:/tmp/our.json")
taw := load("c:/tmp/tawhiri.json")
// Find burst and landing points
var ourBurst, ourLand, tawBurst, tawLand Point
for _, s := range our.Prediction {
t := s.Trajectory
if s.Stage == "ascent" {
ourBurst = t[len(t)-1]
}
if s.Stage == "descent" {
ourLand = t[len(t)-1]
}
}
for _, s := range taw.Prediction {
t := s.Trajectory
if s.Stage == "ascent" {
tawBurst = t[len(t)-1]
}
if s.Stage == "descent" {
tawLand = t[len(t)-1]
}
}
fmt.Println("=== Burst Point ===")
fmt.Printf(" Our: lat=%.4f, lon=%.4f, alt=%.0f, time=%s\n", ourBurst.Latitude, ourBurst.Longitude, ourBurst.Altitude, ourBurst.Datetime)
fmt.Printf(" Tawhiri: lat=%.4f, lon=%.4f, alt=%.0f, time=%s\n", tawBurst.Latitude, tawBurst.Longitude, tawBurst.Altitude, tawBurst.Datetime)
burstDist := haversine(ourBurst.Latitude, ourBurst.Longitude, tawBurst.Latitude, tawBurst.Longitude)
fmt.Printf(" Distance: %.2f km\n", burstDist/1000)
fmt.Println()
fmt.Println("=== Landing Point ===")
fmt.Printf(" Our: lat=%.4f, lon=%.4f, alt=%.0f, time=%s\n", ourLand.Latitude, ourLand.Longitude, ourLand.Altitude, ourLand.Datetime)
fmt.Printf(" Tawhiri: lat=%.4f, lon=%.4f, alt=%.0f, time=%s\n", tawLand.Latitude, tawLand.Longitude, tawLand.Altitude, tawLand.Datetime)
landDist := haversine(ourLand.Latitude, ourLand.Longitude, tawLand.Latitude, tawLand.Longitude)
fmt.Printf(" Distance: %.2f km\n", landDist/1000)
fmt.Println()
fmt.Println("=== Trajectory Comparison (every 10 min) ===")
ourPts := map[string]Point{}
tawPts := map[string]Point{}
for _, s := range our.Prediction {
for _, p := range s.Trajectory {
ourPts[p.Datetime] = p
}
}
for _, s := range taw.Prediction {
for _, p := range s.Trajectory {
tawPts[p.Datetime] = p
}
}
// Collect common times
var common []string
for _, s := range our.Prediction {
for _, p := range s.Trajectory {
if _, ok := tawPts[p.Datetime]; ok {
common = append(common, p.Datetime)
}
}
}
for i, t := range common {
if i%10 == 0 {
o := ourPts[t]
tw := tawPts[t]
d := haversine(o.Latitude, o.Longitude, tw.Latitude, tw.Longitude)
fmt.Printf(" %s: dist=%.2f km (our: %.3f,%.3f vs taw: %.3f,%.3f)\n",
t, d/1000, o.Latitude, o.Longitude, tw.Latitude, tw.Longitude)
}
}
}

View file

@ -1,44 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"time"
"git.intra.yksa.space/gsn/predictor/internal/pkg/grib"
)
func main() {
ctx := context.Background()
cfg := &grib.Config{
Dir: "C:/tmp/grib",
TTL: 48 * time.Hour,
CacheTTL: 1 * time.Hour,
Parallel: 8,
}
svc, err := grib.New(cfg)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Delete old cube to force rebuild
cubePath := "C:/tmp/grib/20260212_12.cube"
if err := os.Remove(cubePath); err != nil && !os.IsNotExist(err) {
fmt.Printf("Remove cube error: %v\n", err)
} else {
fmt.Println("Old cube removed")
}
// Update will download missing pgrb2b files and rebuild cube
fmt.Println("Starting update (download pgrb2b + rebuild cube)...")
start := time.Now()
if err := svc.Update(ctx); err != nil {
fmt.Printf("Update error: %v\n", err)
return
}
fmt.Printf("Done in %v\n", time.Since(start))
}

View file

@ -1,36 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"git.intra.yksa.space/gsn/predictor/internal/pkg/grib"
)
func main() {
ctx := context.Background()
// Найти последний доступный прогноз
run, err := grib.GetLatestModelRun(ctx)
if err != nil {
fmt.Printf("Error finding model run: %v\n", err)
return
}
fmt.Printf("Found model run: %v\n", run)
// Создать downloader
dl := grib.NewPartialDownloader("C:/tmp/grib", 8)
// Запустить загрузку
start := time.Now()
fmt.Println("Starting download...")
err = dl.Run(ctx, run)
if err != nil {
fmt.Printf("Download error: %v\n", err)
return
}
fmt.Printf("Download completed in %v\n", time.Since(start))
}

View file

@ -1,60 +0,0 @@
package main
import (
"encoding/binary"
"fmt"
"math"
"os"
mmap "github.com/edsrzf/mmap-go"
)
var pressureLevels = []float64{
1000, 975, 950, 925, 900, 875, 850, 825, 800, 775,
750, 725, 700, 675, 650, 625, 600, 575, 550, 525,
500, 475, 450, 425, 400, 375, 350, 325, 300, 275,
250, 225, 200, 175, 150, 125, 100, 70, 50, 30,
20, 10, 7, 5, 3, 2, 1,
}
func main() {
f, _ := os.Open("C:/tmp/grib/20260212_12.cube")
mm, _ := mmap.Map(f, mmap.RDONLY, 0)
defer mm.Unmap()
defer f.Close()
const (
nT = 97
nP = 47
nLat = 721
nLon = 1440
)
bytesPerVar := int64(nT * nP * nLat * nLon * 4)
val := func(varIdx, ti, pi, y, x int) float32 {
idx := (((ti*nP + pi) * nLat) + y) * nLon + x
off := int64(varIdx)*bytesPerVar + int64(idx)*4
bits := binary.LittleEndian.Uint32(mm[off : off+4])
return math.Float32frombits(bits)
}
// Check gh values at lat=52.2N (y=(90-52.2)*4=151.2 → y=151), lon=0.1E (x=0.1*4=0.4 → x=0)
// Time step 9 (9 hours into forecast)
ti := 9
y := 151
x := 0
fmt.Println("GH values at (52.25N, 0E), t=+9h:")
fmt.Printf("%8s %8s %10s\n", "Level", "hPa", "GH(m)")
for pi := 0; pi < nP; pi++ {
gh := val(0, ti, pi, y, x)
fmt.Printf("%8d %8.0f %10.1f\n", pi, pressureLevels[pi], gh)
}
fmt.Println("\nU-wind values at same point:")
fmt.Printf("%8s %8s %10s\n", "Level", "hPa", "U(m/s)")
for pi := 0; pi < nP; pi++ {
u := val(1, ti, pi, y, x)
fmt.Printf("%8d %8.0f %10.2f\n", pi, pressureLevels[pi], u)
}
}

View file

@ -1,38 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/nilsmagnus/grib/griblib"
)
func main() {
f, err := os.Open("C:/tmp/grib/gfs.t18z.pgrb2.0p25.f000")
if err != nil {
fmt.Printf("Error opening file: %v\n", err)
return
}
defer f.Close()
messages, err := griblib.ReadMessages(f)
if err != nil {
fmt.Printf("Error reading GRIB: %v\n", err)
return
}
fmt.Printf("Found %d messages\n\n", len(messages))
for i, m := range messages {
product := m.Section4.ProductDefinitionTemplate
if product.ParameterCategory != 2 || product.ParameterNumber != 2 {
continue // only u-wind
}
fmt.Printf("UGRD Msg %d: SurfType=%d SurfValue=%d SurfScale=%d DataLen=%d\n",
i,
product.FirstSurface.Type,
product.FirstSurface.Value,
product.FirstSurface.Scale,
len(m.Data()))
}
}

View file

@ -1,87 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.intra.yksa.space/gsn/predictor/internal/pkg/grib"
)
func main() {
ctx := context.Background()
// Инициализируем GRIB сервис
cfg := &grib.Config{
Dir: "C:/tmp/grib",
TTL: 48 * time.Hour,
CacheTTL: 1 * time.Hour,
Parallel: 8,
}
svc, err := grib.New(cfg)
if err != nil {
fmt.Printf("Error creating service: %v\n", err)
return
}
// Обновляем данные (создаёт куб)
fmt.Println("Updating GRIB data (building cube)...")
start := time.Now()
if err := svc.Update(ctx); err != nil {
fmt.Printf("Update error: %v\n", err)
return
}
fmt.Printf("Cube built in %v\n", time.Since(start))
// Тестируем извлечение ветра
fmt.Println("\nTesting wind extraction...")
lat, lon, alt := 52.2, 0.1, 10000.0
ts := time.Date(2026, 2, 11, 12, 0, 0, 0, time.UTC)
wind, err := svc.Extract(ctx, lat, lon, alt, ts)
if err != nil {
fmt.Printf("Extract error: %v\n", err)
return
}
fmt.Printf("Wind at (%.2f, %.2f, %.0fm) at %v:\n", lat, lon, alt, ts)
fmt.Printf(" U (east): %.2f m/s\n", wind[0])
fmt.Printf(" V (north): %.2f m/s\n", wind[1])
// Сравниваем с Tawhiri
fmt.Println("\nComparing with Tawhiri API...")
tawhiriURL := fmt.Sprintf(
"https://api.v2.sondehub.org/tawhiri?launch_latitude=%.2f&launch_longitude=%.2f&launch_altitude=0&launch_datetime=%s&ascent_rate=5&burst_altitude=30000&descent_rate=5",
lat, lon, ts.Format(time.RFC3339),
)
resp, err := http.Get(tawhiriURL)
if err != nil {
fmt.Printf("Tawhiri request error: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var tawhiriResp map[string]interface{}
json.Unmarshal(body, &tawhiriResp)
// Выводим финальную точку приземления
if prediction, ok := tawhiriResp["prediction"].([]interface{}); ok {
for _, stage := range prediction {
stageMap := stage.(map[string]interface{})
if stageMap["stage"] == "descent" {
trajectory := stageMap["trajectory"].([]interface{})
if len(trajectory) > 0 {
last := trajectory[len(trajectory)-1].(map[string]interface{})
fmt.Printf("\nTawhiri landing point:\n")
fmt.Printf(" Lat: %.4f\n", last["latitude"])
fmt.Printf(" Lon: %.4f\n", last["longitude"])
}
}
}
}
}

View file

@ -1,303 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
import time
import requests
import json
from typing import Any
import base64
import math
# --- Config ---
REFERENCE_API_URL = "https://fly.stratonautica.ru/api/v2/?profile=standard_profile&pred_type=single&launch_datetime=2025-06-25T20%3A45%3A00Z&launch_latitude=56.6992&launch_longitude=38.8247&launch_altitude=0&ascent_rate=5&burst_altitude=30000&descent_rate=5"
LOCAL_API_URL = "http://localhost:8080/api/v1/prediction?profile=standard_profile&pred_type=single&launch_datetime=2025-06-25T20%3A45%3A00Z&launch_latitude=56.6992&launch_longitude=38.8247&launch_altitude=0&ascent_rate=5&burst_altitude=30000&descent_rate=5"
LOCAL_API_PAYLOAD = {
"launch_latitude": 56.6992,
"launch_longitude": 38.8247,
"launch_datetime": "2025-06-25T20-45-000Z",
"launch_altitude": 0,
"profile": "standard_profile",
"ascent_rate": 5,
"burst_altitude": 30000,
"descent_rate": 5,
"format": "json"
}
READY_URL = "http://localhost:8080/ready"
# --- Utility functions ---
def run_compose_up():
print("[INFO] Running docker-compose down --remove-orphans ...")
result = subprocess.run(["docker-compose", "down", "--remove-orphans"], capture_output=True)
if result.returncode != 0:
print("[ERROR] docker-compose down failed:", result.stderr.decode())
sys.exit(1)
print("[INFO] docker-compose down completed.")
print("[INFO] Running docker-compose up -d ...")
result = subprocess.run(["docker-compose", "up", "-d"], capture_output=True)
if result.returncode != 0:
print("[ERROR] docker-compose up failed:", result.stderr.decode())
sys.exit(1)
print("[INFO] docker-compose up -d completed.")
return True
def wait_for_ready(timeout=900):
print(f"[INFO] Waiting for {READY_URL} to be ready ...")
start = time.time()
while time.time() - start < timeout:
try:
resp = requests.get(READY_URL, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get("status") == "ok":
print("[INFO] Service is ready.")
return
else:
print(f"[INFO] Not ready yet: {data}")
else:
print(f"[INFO] /ready returned status {resp.status_code}")
except Exception as e:
print(f"[INFO] Exception while polling /ready: {e}")
time.sleep(10)
print(f"[ERROR] Service did not become ready in {timeout} seconds.")
sys.exit(1)
def fetch_reference():
print(f"[INFO] Fetching reference prediction from {REFERENCE_API_URL}")
resp = requests.get(REFERENCE_API_URL, timeout=60)
if resp.status_code != 200:
print(f"[ERROR] Reference API returned {resp.status_code}: {resp.text}")
sys.exit(1)
return resp.json()
def fetch_local():
print(f"[INFO] Fetching local prediction from {LOCAL_API_URL}")
resp = requests.get(LOCAL_API_URL, timeout=60)
if resp.status_code != 200:
print(f"[ERROR] Local API returned {resp.status_code}: {resp.text}")
sys.exit(1)
return resp.json()
def haversine(lat1, lon1, lat2, lon2):
"""Calculate the great-circle distance between two points on the Earth (specified in decimal degrees). Returns distance in kilometers."""
R = 6371.0 # Earth radius in kilometers
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
def compare_results(reference_data, local_data):
"""Compare prediction results between reference and local APIs."""
print("[INFO] Comparing results ...")
# Extract trajectory data
ref_trajectory = reference_data.get('prediction', [{}])[0].get('trajectory', [])
local_trajectory = local_data.get('prediction', [{}])[0].get('trajectory', [])
print(f"[DEBUG] Reference trajectory length: {len(ref_trajectory)}")
print(f"[DEBUG] Local trajectory length: {len(local_trajectory)}")
# Show first 3 points from both APIs
print("\n[DEBUG] First 3 points - Reference API:")
for i, point in enumerate(ref_trajectory[:3]):
print(f" [{i}] alt={point.get('altitude', 'N/A')}, lat={point.get('latitude', 'N/A')}, lon={point.get('longitude', 'N/A')}, time={point.get('datetime', 'N/A')}")
print("\n[DEBUG] First 3 points - Local API:")
for i, point in enumerate(local_trajectory[:3]):
print(f" [{i}] alt={point.get('altitude', 'N/A')}, lat={point.get('latitude', 'N/A')}, lon={point.get('longitude', 'N/A')}, time={point.get('datetime', 'N/A')}")
# Show last 3 points from both APIs
print("\n[DEBUG] Last 3 points - Reference API:")
for i, point in enumerate(ref_trajectory[-3:]):
idx = len(ref_trajectory) - 3 + i
print(f" [{idx}] alt={point.get('altitude', 'N/A')}, lat={point.get('latitude', 'N/A')}, lon={point.get('longitude', 'N/A')}, time={point.get('datetime', 'N/A')}")
print("\n[DEBUG] Last 3 points - Local API:")
for i, point in enumerate(local_trajectory[-3:]):
idx = len(local_trajectory) - 3 + i
print(f" [{idx}] alt={point.get('altitude', 'N/A')}, lat={point.get('latitude', 'N/A')}, lon={point.get('longitude', 'N/A')}, time={point.get('datetime', 'N/A')}")
# Compare trajectory lengths
if len(ref_trajectory) != len(local_trajectory):
print(f"[DIFF] Trajectory length mismatch: {len(local_trajectory)} vs {len(ref_trajectory)}")
return False
# Compare trajectory points and calculate drift
min_len = min(len(ref_trajectory), len(local_trajectory))
max_drift = 0.0
max_drift_idx = -1
drift_list = []
print("\n[DRIFT] Trajectory point-by-point distance (km):")
for i in range(min_len):
ref_point = ref_trajectory[i]
local_point = local_trajectory[i]
ref_lat = ref_point.get('latitude')
ref_lon = ref_point.get('longitude')
local_lat = local_point.get('latitude')
local_lon = local_point.get('longitude')
drift_km = None
if None not in (ref_lat, ref_lon, local_lat, local_lon):
drift_km = haversine(ref_lat, ref_lon, local_lat, local_lon)
drift_list.append(drift_km)
if drift_km > max_drift:
max_drift = drift_km
max_drift_idx = i
print(f" [{i}] Drift: {drift_km:.3f} km")
else:
print(f" [{i}] Drift: N/A (missing lat/lon)")
if drift_list:
mean_drift = sum(drift_list) / len(drift_list)
print(f"\n[DRIFT] Max drift: {max_drift:.3f} km at idx {max_drift_idx}")
print(f"[DRIFT] Mean drift: {mean_drift:.3f} km over {len(drift_list)} points")
else:
print("[DRIFT] No valid drift data to report.")
# Continue with original comparison for altitude, etc.
for i in range(min_len):
ref_point = ref_trajectory[i]
local_point = local_trajectory[i]
for key in ['altitude', 'latitude', 'longitude']:
ref_val = ref_point.get(key)
local_val = local_point.get(key)
if ref_val is not None and local_val is not None:
if abs(ref_val - local_val) > 0.1:
print(f"[DIFF] At idx {i}, key {key}: {local_val} != {ref_val}")
return False
print("[SUCCESS] Results match!")
return True
def test_custom_profile():
"""Test custom profile with base64 encoded curve."""
print("\n[TEST] Testing custom_profile...")
# Create a simple custom ascent curve (altitude vs time in seconds)
curve_data = {
"altitude": [0, 30000],
"time": [0, 6000]
}
curve_b64 = base64.b64encode(json.dumps(curve_data).encode()).decode()
# Test parameters for custom profile
params = {
"launch_latitude": 56.6992,
"launch_longitude": 38.8247,
"launch_datetime": "2025-06-25T13:28:00Z",
"launch_altitude": 0,
"profile": "custom_profile",
"ascent_curve": curve_b64
}
try:
# Test local API (use GET)
local_resp = requests.get(
"http://localhost:8080/api/v1/prediction",
params=params,
timeout=30
)
local_resp.raise_for_status()
local_data = local_resp.json()
print(f"[INFO] Custom profile test - Local API returned {len(local_data.get('prediction', [{}])[0].get('trajectory', []))} trajectory points")
return True
except Exception as e:
print(f"[ERROR] Custom profile test failed: {e}")
return False
def test_all_profiles():
"""Test all available profiles."""
profiles = [
("standard_profile", "Standard profile test"),
("float_profile", "Float profile test"),
("reverse_profile", "Reverse profile test"),
("custom_profile", "Custom profile test")
]
results = {}
for profile, description in profiles:
print(f"\n[TEST] {description}...")
if profile == "custom_profile":
success = test_custom_profile()
else:
success = test_single_profile(profile)
results[profile] = success
print(f"[RESULT] {profile}: {'PASS' if success else 'FAIL'}")
# Print summary
print("\n" + "="*50)
print("TEST SUMMARY")
print("="*50)
for profile, success in results.items():
status = "PASS" if success else "FAIL"
print(f"{profile:20} : {status}")
total_tests = len(results)
passed_tests = sum(results.values())
print(f"\nTotal tests: {total_tests}, Passed: {passed_tests}, Failed: {total_tests - passed_tests}")
return all(results.values())
def test_single_profile(profile):
"""Test a single profile against reference API."""
# Test parameters
params = {
"launch_latitude": 56.6992,
"launch_longitude": 38.8247,
"launch_datetime": "2025-06-25T13:28:00Z",
"launch_altitude": 0,
"profile": profile,
"ascent_rate": 5,
"burst_altitude": 30000,
"descent_rate": 5
}
# Add float altitude for float profile
if profile == "float_profile":
params["float_altitude"] = 25000
try:
# Test local API (use GET)
local_resp = requests.get(
"http://localhost:8080/api/v1/prediction",
params=params,
timeout=30
)
local_resp.raise_for_status()
local_data = local_resp.json()
print(f"[INFO] {profile} - Local API returned {len(local_data.get('prediction', [{}])[0].get('trajectory', []))} trajectory points")
return True
except Exception as e:
print(f"[ERROR] {profile} test failed: {e}")
return False
def main():
"""Main test function."""
print("[INFO] Starting comprehensive predictor API tests...")
# Run the original standard profile test
print("\n[TEST] Running original standard_profile test...")
run_compose_up()
wait_for_ready()
ref = fetch_reference()
local = fetch_local()
print("[INFO] Comparing results ...")
original_success = compare_results(ref, local)
if original_success:
print("[SUCCESS] Original standard_profile test passed!")
else:
print("[FAIL] Original standard_profile test failed!")
# Test all profiles
print("\n[TEST] Running all profile tests...")
all_profiles_success = test_all_profiles()
# Final result
overall_success = original_success and all_profiles_success
print(f"\n[FINAL RESULT] Overall: {'PASS' if overall_success else 'FAIL'}")
if overall_success:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,55 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"git.intra.yksa.space/gsn/predictor/internal/pkg/grib"
)
func main() {
ctx := context.Background()
cfg := &grib.Config{
Dir: "C:/tmp/grib",
TTL: 48 * time.Hour,
CacheTTL: 1 * time.Hour,
Parallel: 8,
}
svc, err := grib.New(cfg)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
if err := svc.Update(ctx); err != nil {
fmt.Printf("Update error: %v\n", err)
return
}
// Test wind at lat=52.2, lon=0.1 at various altitudes
// Run is 2026-02-12T12:00Z, request time 21:00Z = +9 hours
ts := time.Date(2026, 2, 12, 21, 0, 0, 0, time.UTC)
lat, lon := 52.2, 0.1
fmt.Println("Wind at (52.2°N, 0.1°E) at 2026-02-12T21:00Z:")
fmt.Printf("%8s %8s %8s\n", "Alt(m)", "U(m/s)", "V(m/s)")
for _, alt := range []float64{0, 1000, 3000, 5000, 7000, 10000, 15000, 20000, 25000, 30000} {
w, err := svc.Extract(ctx, lat, lon, alt, ts)
if err != nil {
fmt.Printf("%8.0f Error: %v\n", alt, err)
continue
}
fmt.Printf("%8.0f %8.2f %8.2f\n", alt, w[0], w[1])
}
// Also test at a few nearby points to check spatial consistency
fmt.Println("\nWind at 10km altitude, varying longitude:")
for _, testLon := range []float64{0.0, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 350.0, 359.75} {
w, _ := svc.Extract(ctx, lat, testLon, 10000, ts)
fmt.Printf(" lon=%6.2f: U=%8.2f V=%8.2f\n", testLon, w[0], w[1])
}
}