fix(input): correct lon mapping

This commit is contained in:
gili8420 2026-08-04 13:40:50 +09:00
parent 19557f3a62
commit 84d1664b29
20 changed files with 1197 additions and 137 deletions

View file

@ -24,6 +24,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"text/tabwriter"
"time"
)
@ -75,15 +76,22 @@ func main() {
var worst float64
compared := 0
for _, s := range sites {
p := params{lat: s.lat, lng: s.lng, alt: *alt, launch: launchTime,
// The site suite compares trajectories, not notations, so fold western
// longitudes into [0, 360) here: that is the only range Tawhiri accepts.
// The longitude suite below sends raw values precisely to test that edge.
lng := s.lng
if lng < 0 {
lng += 360
}
p := params{lat: s.lat, lng: lng, alt: *alt, launch: launchTime,
ascent: *ascent, burst: *burst, descent: *descent}
ours, err := predict(*server+"/api/v1/prediction", p, "")
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)
theirs, _, err := predict(*tawhiri, p, datasetParam)
if err != nil {
fmt.Fprintf(tw, "%s\ttawhiri error: %v\n", s.name, err)
continue
@ -106,10 +114,17 @@ func main() {
}
tw.Flush()
breaks := comparePairs(*server, *tawhiri, longitudeParams(launchTime, *alt, *ascent, *burst, *descent), datasetParam)
breaks += compareLongitudes(*server, *tawhiri, longitudeParams(launchTime, *alt, *ascent, *burst, *descent), datasetParam)
if compared == 0 {
fmt.Println("\nVERDICT: NO COMPARISONS (every site errored — see rows above)")
os.Exit(1)
}
if breaks > 0 {
fmt.Printf("\nVERDICT: INCOMPATIBLE (%d longitude(s) Tawhiri accepts and we reject)\n", breaks)
os.Exit(3)
}
fmt.Printf("\ncompared %d/%d sites; worst landing distance: %.2f km\n", compared, len(sites), worst/1000)
switch {
case worst < 1000:
@ -153,17 +168,20 @@ type result struct {
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
}
// predict sends p verbatim and returns the parsed result plus the HTTP status.
//
// Nothing is normalised here on purpose. This function used to fold negative
// longitudes into [0, 360) before sending, which made every longitude notation
// look identical to both endpoints — so the tool could not see a disagreement
// about notation even in principle, and ours drifted to [-180, 360) with an
// asymmetry nobody had chosen. Callers that want a fold do it themselves.
//
// The status is returned separately from the error so a caller can tell a
// deliberate rejection from a transport failure.
func predict(endpoint string, p params, dataset string) (result, int, error) {
q := url.Values{}
q.Set("launch_latitude", fmt.Sprintf("%.4f", p.lat))
q.Set("launch_longitude", fmt.Sprintf("%.4f", lng))
q.Set("launch_longitude", fmt.Sprintf("%.4f", p.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))
@ -191,10 +209,10 @@ func predict(endpoint string, p params, dataset string) (result, error) {
break
}
if lastErr != nil {
return result{}, lastErr
return result{}, 0, lastErr
}
if status != 200 {
return result{}, fmt.Errorf("HTTP %d: %s", status, truncate(string(body), 160))
return result{}, status, fmt.Errorf("HTTP %d: %s", status, truncate(string(body), 160))
}
var doc struct {
@ -211,7 +229,7 @@ func predict(endpoint string, p params, dataset string) (result, error) {
} `json:"request"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return result{}, err
return result{}, status, err
}
var r result
@ -230,7 +248,7 @@ func predict(endpoint string, p params, dataset string) (result, error) {
r.landLat, r.landLng, r.landAlt = last.Latitude, last.Longitude, last.Altitude
}
}
return r, nil
return r, status, nil
}
type readinessResp struct {
@ -274,3 +292,173 @@ func truncate(s string, n int) string {
}
return s[:n] + "…"
}
// longitudeCases are the notations a client can put in launch_longitude, sent
// verbatim to both endpoints.
//
// Longitude is on a circle, so every one of these names a real meridian and no
// value here is geometrically wrong; what differs is which notations each service
// agrees to read. Tawhiri implements a half-open [0, 360) — one clean fundamental
// domain, every meridian spelled exactly once. We accept a superset deliberately,
// because the frontend holds longitudes in [-180, 180] and a western launch
// therefore arrives negative.
//
// The pairs below name the same meridian in the two notations, so an accepted
// pair must produce the same trajectory: (-51.7, 308.3), (-180, 180), (0, 360),
// (-90, 270). That is what makes this a comparison rather than a status table.
func longitudeCases() []float64 {
return []float64{
0, 180, 270, 308.3, 359.999, // inside Tawhiri's domain
-0.0001, -51.7, -90, -180, -200, // signed convention: our extension
360, // 0 spelled redundantly; Tawhiri refuses it
5170, // a plain typo, refused by both
}
}
func longitudeParams(launch time.Time, alt, ascent, burst, descent float64) params {
// 64.1 N is Nuuk's latitude: high enough to be a realistic Arctic site, far
// enough from the pole that the comparison is about longitude alone.
return params{lat: 64.1, alt: alt, launch: launch, ascent: ascent, burst: burst, descent: descent}
}
// compareLongitudes reports how the two services read each notation and returns
// the number of compatibility breaks — longitudes Tawhiri accepts and we refuse.
// Those are the only rows that are a defect: refusing what a Tawhiri client is
// entitled to send is what a drop-in replacement must never do. The reverse, us
// accepting what Tawhiri refuses, is the extension the product depends on.
func compareLongitudes(server, tawhiri string, base params, datasetParam string) int {
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "\nlongitude\tours\ttawhiri\tlanding Δ\tverdict")
fmt.Fprintln(tw, "---------\t----\t-------\t---------\t-------")
breaks := 0
for _, lng := range longitudeCases() {
p := base
p.lng = lng
ours, oursStatus, oursErr := predict(server+"/api/v1/prediction", p, "")
theirs, theirsStatus, theirsErr := predict(tawhiri, p, datasetParam)
code := func(status int, err error) string {
if err == nil {
return "200"
}
if status == 0 {
return "transport"
}
return fmt.Sprintf("%d", status)
}
delta := ""
verdict := ""
switch {
case oursErr == nil && theirsErr == nil:
d := haversine(ours.landLat, ours.landLng, theirs.landLat, theirs.landLng)
delta = fmt.Sprintf("%.2f km", d/1000)
verdict = "agree"
if d > 50000 {
verdict = "ACCEPTED BY BOTH, TRAJECTORIES DIVERGE"
}
// v1 must publish the convention Tawhiri publishes, not merely the same
// place in a different notation. It used to answer -51.2115 where
// Tawhiri answers 308.7884; haversine cannot see that, because a 360
// degree difference vanishes from sin(dlam/2).
if (ours.landLng < 0) != (theirs.landLng < 0) {
verdict = fmt.Sprintf("OUTPUT FORMAT DIFFERS (ours %.4f, theirs %.4f)", ours.landLng, theirs.landLng)
breaks++
}
case !aboutLongitude(oursErr) || !aboutLongitude(theirsErr):
// A rejection for some other reason says nothing about notation. Before
// this check, an upstream "No matching dataset found" — which happens as
// soon as sondehub rotates past our GFS run — was reported as "Tawhiri
// refuses this longitude", i.e. the tool inventing compatibility news.
verdict = "inconclusive: " + firstReason(oursErr, theirsErr)
case oursErr != nil && theirsErr != nil:
verdict = "agree (both refuse)"
case oursErr != nil:
verdict = "BREAK: Tawhiri accepts, we refuse"
breaks++
default:
verdict = "extension: we accept, Tawhiri refuses"
}
fmt.Fprintf(tw, "%g\t%s\t%s\t%s\t%s\n",
lng, code(oursStatus, oursErr), code(theirsStatus, theirsErr), delta, verdict)
}
tw.Flush()
return breaks
}
// aboutLongitude reports whether err is a rejection of the longitude itself
// rather than of something else in the request. A nil error passes: a successful
// call is always informative.
func aboutLongitude(err error) bool {
if err == nil {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "longitude")
}
// firstReason summarises whichever side failed for an unrelated reason.
func firstReason(errs ...error) string {
for _, err := range errs {
if err != nil && !aboutLongitude(err) {
return truncate(err.Error(), 90)
}
}
return "unknown"
}
// longitudePairs are two notations naming one meridian: signed, then unsigned.
//
// Tawhiri reads only the unsigned one, so this is the only way the signed form —
// the extension the product actually depends on — can be checked against the
// reference at all. Our answer for the signed value must match Tawhiri's answer
// for its unsigned twin. Without this the "extension" rows in the table above are
// merely unrefuted, not verified.
//
// (0, 360) is absent on purpose: Tawhiri refuses 360, so that pair has no
// reference side.
func longitudePairs() [][2]float64 {
return [][2]float64{{-51.7, 308.3}, {-180, 180}, {-90, 270}, {-0.0001, 359.9999}}
}
// comparePairs checks each signed notation we accept against Tawhiri's answer for
// the same meridian written the way Tawhiri accepts it. Returns the number of
// pairs that disagree by more than the site suite's own tolerance.
func comparePairs(server, tawhiri string, base params, datasetParam string) int {
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "\nours (signed)\tvs tawhiri (unsigned)\tlanding Δ\tverdict")
fmt.Fprintln(tw, "-------------\t---------------------\t---------\t-------")
bad := 0
for _, pair := range longitudePairs() {
signed, unsigned := pair[0], pair[1]
p := base
p.lng = signed
ours, _, oursErr := predict(server+"/api/v1/prediction", p, "")
q := base
q.lng = unsigned
theirs, _, theirsErr := predict(tawhiri, q, datasetParam)
switch {
case oursErr != nil:
fmt.Fprintf(tw, "%g\t%g\t\tours refused: %v\n", signed, unsigned, oursErr)
bad++
case theirsErr != nil:
fmt.Fprintf(tw, "%g\t%g\t\ttawhiri refused the unsigned twin: %v\n", signed, unsigned, theirsErr)
bad++
default:
d := haversine(ours.landLat, ours.landLng, theirs.landLat, theirs.landLng)
verdict := "agree — the extension reads the same meridian"
if d > 50000 {
verdict = "DIVERGENT — signed notation is not the same meridian"
bad++
}
fmt.Fprintf(tw, "%g\t%g\t%.2f km\t%s\n", signed, unsigned, d/1000, verdict)
}
}
tw.Flush()
return bad
}