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

@ -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,
})
}