fix(input): correct lon mapping
This commit is contained in:
parent
19557f3a62
commit
84d1664b29
20 changed files with 1197 additions and 137 deletions
|
|
@ -2,22 +2,72 @@ package api
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/api/async"
|
||||
"predictor-refactored/internal/engine"
|
||||
"predictor-refactored/internal/numerics"
|
||||
apirest "predictor-refactored/pkg/rest"
|
||||
)
|
||||
|
||||
// normalizeLng folds a longitude into [0, 360) for internal use.
|
||||
func normalizeLng(lng float64) float64 {
|
||||
if lng < 0 {
|
||||
return lng + 360
|
||||
// longitudeLimit bounds what either API version accepts, in degrees.
|
||||
//
|
||||
// Symmetric on purpose. Upstream Tawhiri implements a half-open [0, 360) — one
|
||||
// fundamental domain, every meridian spelled exactly once (measured). We accept a
|
||||
// superset because the frontend holds longitudes in [-180, 180], so a western
|
||||
// launch arrives negative, and we tolerate one full turn either way. Symmetry is
|
||||
// the whole point: the previous bound, [-180, 360), had its two ends justified by
|
||||
// unrelated things — the lower end was the signed convention's edge, the upper end
|
||||
// was the domain on which the old single-fold normalizeLng happened to be correct.
|
||||
//
|
||||
// Beyond one turn a value is a typo rather than a notation. 5170 is arithmetically
|
||||
// a perfectly good way to write 50 E, and upstream refuses it too.
|
||||
const longitudeLimit = 360.0
|
||||
|
||||
// validateLat refuses latitudes outside [-90, 90].
|
||||
//
|
||||
// Shared by both API versions. Unlike longitude this is a real geometric bound:
|
||||
// the poles are the ends of the axis, so no notation makes 100 meaningful. v2
|
||||
// checked it and v1 did not.
|
||||
func validateLat(lat float64) error {
|
||||
if math.IsNaN(lat) || lat < -90 || lat > 90 {
|
||||
return apiError(http.StatusBadRequest, fmt.Sprintf("launch latitude must be in [-90, 90], got %g", lat))
|
||||
}
|
||||
return lng
|
||||
return nil
|
||||
}
|
||||
|
||||
// signedLng converts an internal [0, 360) longitude back to [-180, 180).
|
||||
// validateLng refuses longitudes outside [-longitudeLimit, longitudeLimit].
|
||||
//
|
||||
// Shared by both API versions. v1 used to apply no check at all while v2 enforced
|
||||
// [-180, 360), so the same launch could be accepted by one and refused by the
|
||||
// other — a difference in behaviour where only format may differ.
|
||||
func validateLng(lng float64) error {
|
||||
if math.IsNaN(lng) || lng < -longitudeLimit || lng > longitudeLimit {
|
||||
return apiError(http.StatusBadRequest,
|
||||
fmt.Sprintf("launch longitude must be in [-%g, %g], got %g", longitudeLimit, longitudeLimit, lng))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeLng folds a longitude into the [0, 360) the wind grid is indexed on.
|
||||
//
|
||||
// A true modulo, via the same helper the integrator applies on every step
|
||||
// (numerics.PyMod in toGeo), so the request path and the engine agree by
|
||||
// construction rather than by coincidence. It used to be a single
|
||||
// `if lng < 0 { lng += 360 }`, correct only on [-360, 360): -400 became -40,
|
||||
// reached the grid, and came back as "lng=-40 out of range" — a complaint about
|
||||
// wind data for what was an input problem.
|
||||
func normalizeLng(lng float64) float64 {
|
||||
return numerics.PyMod(lng, 360)
|
||||
}
|
||||
|
||||
// signedLng converts an internal [0, 360) longitude to [-180, 180).
|
||||
//
|
||||
// This is v2's published format. v1 publishes the internal [0, 360) unchanged,
|
||||
// because that is the convention upstream Tawhiri publishes and v1 exists to be a
|
||||
// drop-in for it.
|
||||
func signedLng(lng float64) float64 {
|
||||
if lng > 180 {
|
||||
return lng - 360
|
||||
|
|
|
|||
88
internal/api/mapping_test.go
Normal file
88
internal/api/mapping_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Longitude handling is shared by both API versions on purpose: the two may
|
||||
// differ in format, names and features, never in what the maths does. v1 used to
|
||||
// apply no range check at all while v2 enforced [-180, 360), so the same launch
|
||||
// could be accepted by one and refused by the other.
|
||||
|
||||
func TestValidateLngAcceptsBothConventionsAndOneTurnEitherWay(t *testing.T) {
|
||||
// Upstream Tawhiri implements a half-open [0, 360) — measured: 0, 180, 270 and
|
||||
// 308.3 accepted, everything negative and 360 refused. We accept a superset
|
||||
// because the frontend holds longitudes in [-180, 180], so a western launch
|
||||
// arrives negative. The bound is symmetric so there is nothing to explain
|
||||
// about one end that does not hold for the other.
|
||||
for _, lng := range []float64{0, 0.1, 180, 270, 308.3, 359.999, 360, -0.0001, -51.7, -180, -200, -360} {
|
||||
if err := validateLng(lng); err != nil {
|
||||
t.Errorf("validateLng(%g) = %v, want accepted", lng, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLngRefusesTypos(t *testing.T) {
|
||||
// Arithmetically every one of these names a real meridian — 5170 is 50 E. They
|
||||
// are refused because they are far likelier to be a unit slip or a swapped
|
||||
// field than an intended notation, and upstream refuses them too.
|
||||
for _, lng := range []float64{360.0001, -360.0001, 400, -400, 5170, math.Inf(1), math.Inf(-1)} {
|
||||
if err := validateLng(lng); err == nil {
|
||||
t.Errorf("validateLng(%g) was accepted, want refused", lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLngFoldsAnyInputIntoTheGridDomain(t *testing.T) {
|
||||
// The wind grid is indexed from LonStart = 0 over 360 degrees, so every value
|
||||
// handed to it must land in [0, 360).
|
||||
//
|
||||
// This was a single `if lng < 0 { lng += 360 }`, correct only on [-360, 360):
|
||||
// -400 became -40, reached the grid, and came back as "lng=-40 out of range" —
|
||||
// a complaint about wind data for what was an input problem.
|
||||
cases := map[float64]float64{
|
||||
0: 0, 0.5: 0.5, 180: 180, 359.5: 359.5,
|
||||
360: 0, 720: 0, -360: 0,
|
||||
-0.5: 359.5, -51.7: 308.3, -180: 180, -200: 160, -400: 320, 5170: 130,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeLng(in); math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("normalizeLng(%g) = %g, want %g", in, got, want)
|
||||
}
|
||||
if got := normalizeLng(in); got < 0 || got >= 360 {
|
||||
t.Errorf("normalizeLng(%g) = %g, outside [0, 360)", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedLngIsTheV2Format(t *testing.T) {
|
||||
// v2 publishes [-180, 180): its own convenient format. v1 publishes the
|
||||
// internal [0, 360) unchanged, because that is what upstream Tawhiri publishes
|
||||
// and v1 exists to be a drop-in for it.
|
||||
cases := map[float64]float64{0: 0, 90: 90, 180: 180, 180.5: -179.5, 308.3: -51.7, 359.5: -0.5}
|
||||
for in, want := range cases {
|
||||
if got := signedLng(in); math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("signedLng(%g) = %g, want %g", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLatIsSharedByBothVersions(t *testing.T) {
|
||||
// Unlike longitude, latitude is a genuine geometric bound: +-90 are the poles
|
||||
// and there is nothing beyond them, so no notation makes 100 meaningful.
|
||||
//
|
||||
// v2 checked this and v1 did not, which is a difference in behaviour rather
|
||||
// than format. On v1 an out-of-range latitude reached the wind grid and came
|
||||
// back as "lat=... out of range" — a complaint about data for an input problem.
|
||||
for _, lat := range []float64{-90, -89.999, 0, 52.2, 89.999, 90} {
|
||||
if err := validateLat(lat); err != nil {
|
||||
t.Errorf("validateLat(%g) = %v, want accepted", lat, err)
|
||||
}
|
||||
}
|
||||
for _, lat := range []float64{-90.0001, 90.0001, 100, -100, math.NaN(), math.Inf(1)} {
|
||||
if err := validateLat(lat); err == nil {
|
||||
t.Errorf("validateLat(%g) was accepted, want refused", lat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
|
@ -23,9 +24,45 @@ func (h *Handler) ReadinessCheck(_ context.Context) (*apirest.ReadinessResponse,
|
|||
return resp, nil
|
||||
}
|
||||
|
||||
// fieldFor resolves the dataset a request asked for, or the active one.
|
||||
//
|
||||
// A request naming a dataset that is not stored is refused. The parameter used
|
||||
// to be declared in the spec and never read, so a client asking for a specific
|
||||
// forecast run — an archival date, a reproducible comparison — was silently
|
||||
// served from whatever happened to be loaded instead.
|
||||
func (h *Handler) fieldFor(ctx context.Context, want apirest.OptDateTime) (weather.WindField, error) {
|
||||
if epoch, ok := want.Get(); ok {
|
||||
field, err := h.mgr.FieldFor(ctx, epoch.UTC())
|
||||
if err != nil {
|
||||
return nil, apiError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// runFailure turns an integration failure into a 400.
|
||||
//
|
||||
// The sampler reporting an error means it had no data — most often a launch time
|
||||
// past the dataset's horizon — so the trajectory is not a forecast. It used to be
|
||||
// discarded, and the run continued with zero wind: the balloon took off and
|
||||
// landed on the same spot, returned as a successful prediction.
|
||||
func runFailure(events *engine.EventSink, field weather.WindField) error {
|
||||
err := events.Err()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return apiError(http.StatusBadRequest,
|
||||
fmt.Sprintf("%s (dataset %s)", err, field.Epoch().UTC().Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
// PerformPredictionV2 implements POST /api/v2/prediction.
|
||||
func (h *Handler) PerformPredictionV2(_ context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
resp, err := h.runPredictionV2(req)
|
||||
func (h *Handler) PerformPredictionV2(ctx context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
resp, err := h.runPredictionV2(ctx, req)
|
||||
if err == nil {
|
||||
h.metrics.Prediction("v2", resp.CompletedAt.Sub(resp.StartedAt), nil)
|
||||
}
|
||||
|
|
@ -60,23 +97,22 @@ func (h *Handler) CancelPredictionJob(_ context.Context, params apirest.CancelPr
|
|||
|
||||
// runPredictionV2 is the synchronous prediction core, shared by the v2
|
||||
// endpoint and the async worker pool.
|
||||
func (h *Handler) runPredictionV2(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
func (h *Handler) runPredictionV2(ctx context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
// Validate the request shape before checking dataset availability, so a
|
||||
// malformed request is a 400 regardless of startup state.
|
||||
lat := req.Launch.Latitude
|
||||
rawLng := req.Launch.Longitude
|
||||
alt := req.Launch.Altitude.Or(0)
|
||||
if lat < -90 || lat > 90 {
|
||||
return nil, apiError(http.StatusBadRequest, "launch.latitude must be in [-90, 90]")
|
||||
if err := validateLat(lat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rawLng < -180 || rawLng >= 360 {
|
||||
return nil, apiError(http.StatusBadRequest, "launch.longitude must be in [-180, 360)")
|
||||
if err := validateLng(req.Launch.Longitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lng := normalizeLng(rawLng)
|
||||
lng := normalizeLng(req.Launch.Longitude)
|
||||
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
field, err := h.fieldFor(ctx, req.Dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events := engine.NewEventSink()
|
||||
|
|
@ -90,6 +126,9 @@ func (h *Handler) runPredictionV2(req *apirest.PredictionV2Request) (*apirest.Pr
|
|||
started := time.Now().UTC()
|
||||
results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{Lat: lat, Lng: lng, Altitude: alt}, events)
|
||||
completed := time.Now().UTC()
|
||||
if err := runFailure(events, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &apirest.PredictionV2Response{
|
||||
Stages: make([]apirest.StageResult, 0, len(results)),
|
||||
|
|
@ -105,10 +144,10 @@ func (h *Handler) runPredictionV2(req *apirest.PredictionV2Request) (*apirest.Pr
|
|||
}
|
||||
|
||||
// PerformPrediction implements GET /api/v1/prediction (Tawhiri-compatible).
|
||||
func (h *Handler) PerformPrediction(_ context.Context, params apirest.PerformPredictionParams) (*apirest.PredictionResponse, error) {
|
||||
field := h.mgr.Active()
|
||||
if field == nil {
|
||||
return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up")
|
||||
func (h *Handler) PerformPrediction(ctx context.Context, params apirest.PerformPredictionParams) (*apirest.PredictionResponse, error) {
|
||||
field, err := h.fieldFor(ctx, params.Dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profileKind := "standard_profile"
|
||||
|
|
@ -118,6 +157,12 @@ func (h *Handler) PerformPrediction(_ context.Context, params apirest.PerformPre
|
|||
ascentRate := params.AscentRate.Or(5)
|
||||
descentRate := params.DescentRate.Or(5)
|
||||
launchAlt := params.LaunchAltitude.Or(0)
|
||||
if err := validateLat(params.LaunchLatitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLng(params.LaunchLongitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lng := normalizeLng(params.LaunchLongitude)
|
||||
launchTime := float64(params.LaunchDatetime.Unix())
|
||||
|
||||
|
|
@ -142,6 +187,9 @@ func (h *Handler) PerformPrediction(_ context.Context, params apirest.PerformPre
|
|||
started := time.Now().UTC()
|
||||
results := prof.Run(launchTime, engine.State{Lat: params.LaunchLatitude, Lng: lng, Altitude: launchAlt}, events)
|
||||
completed := time.Now().UTC()
|
||||
if err := runFailure(events, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.metrics.Prediction(profileKind, completed.Sub(started), nil)
|
||||
|
||||
resp := &apirest.PredictionResponse{
|
||||
|
|
@ -229,9 +277,13 @@ func tawhiriItem(name string, r engine.Result) apirest.PredictionResponsePredict
|
|||
for i := range n {
|
||||
t, p := r.Path.At(i)
|
||||
traj = append(traj, apirest.TawhiriPoint{
|
||||
Datetime: time.Unix(int64(t), 0).UTC(),
|
||||
Latitude: p.Lat,
|
||||
Longitude: signedLng(p.Lng),
|
||||
Datetime: time.Unix(int64(t), 0).UTC(),
|
||||
Latitude: p.Lat,
|
||||
// v1 publishes [0, 360), the convention upstream Tawhiri publishes —
|
||||
// measured: it answers 308.7884 where we used to answer -51.2115 for
|
||||
// the same meridian. v1 exists to be a drop-in, so it matches. v2 keeps
|
||||
// the signed format (mapping.go).
|
||||
Longitude: p.Lng,
|
||||
Altitude: p.Altitude,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,11 @@ func New(port int, d Deps) (*Server, error) {
|
|||
Workers: d.AsyncWorkers,
|
||||
QueueSize: d.AsyncQueueSize,
|
||||
ResultTTL: d.AsyncResultTTL,
|
||||
}, h.runPredictionV2, d.Metrics, d.Log)
|
||||
// The async queue outlives the HTTP request that enqueued the job, so a
|
||||
// worker cannot inherit its context.
|
||||
}, func(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) {
|
||||
return h.runPredictionV2(context.Background(), req)
|
||||
}, d.Metrics, d.Log)
|
||||
|
||||
ogenSrv, err := apirest.NewServer(h, apirest.WithMiddleware(middleware.OgenLogging(d.Log)))
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue