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

@ -1,7 +1,9 @@
package engine
import (
"fmt"
"math"
"strings"
"testing"
"time"
@ -261,3 +263,157 @@ func TestPolygonOutsideAntimeridian(t *testing.T) {
t.Errorf("(0, 0) should be outside")
}
}
// horizonWind has data up to horizon seconds and fails past it, the way a real
// dataset's time axis does (numerics.AxisError from Axis.Locate).
type horizonWind struct{ horizon float64 }
func (w horizonWind) Wind(t, _, _, _ float64) (weather.Sample, error) {
if t > w.horizon {
return weather.Sample{}, fmt.Errorf("hour=%v out of range", t/3600)
}
return weather.Sample{U: 10, V: 0}, nil
}
func (w horizonWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
func (w horizonWind) Source() string { return "test" }
// TestWindSamplingFailureIsRecordedNotSwallowed pins the difference between an
// event and a failure.
//
// WindTransport used to discard the sampler's error and return a zero rate. A
// zero rate is indistinguishable from calm air, so a launch past the dataset's
// horizon integrated with no wind at all and produced a balloon that took off
// and landed on the same spot — returned as a successful forecast. An error from
// the sampler means there was no data, so the trajectory past that point is not
// a forecast and must not be presented as one.
func TestWindSamplingFailureIsRecordedNotSwallowed(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(horizonWind{horizon: 600}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
err := sink.Err()
if err == nil {
t.Fatal("sink reports no failure; the sampling error was swallowed")
}
if !strings.Contains(err.Error(), "out of range") {
t.Errorf("error %q does not carry the sampler's reason", err)
}
}
// TestWindSamplingSuccessLeavesNoFailure keeps the check above from firing on
// healthy runs, which would turn every prediction into a 400.
func TestWindSamplingSuccessLeavesNoFailure(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(horizonWind{horizon: 1e9}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
if err := sink.Err(); err != nil {
t.Fatalf("healthy run reported a failure: %v", err)
}
}
// TestAboveModelStaysNonFatal separates the two paths explicitly: extrapolating
// above the highest pressure level is a warning the caller may ignore, and must
// not become a hard failure.
func TestAboveModelStaysNonFatal(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(aboveModelWind{}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
if err := sink.Err(); err != nil {
t.Fatalf("above_model became a failure: %v", err)
}
if len(sink.Snapshot()) == 0 {
t.Error("above_model event was not emitted")
}
}
// TestEveryModelHonoursIncludeWind pins the promise ModelSpec.IncludeWind makes.
//
// buildConstantRate and buildParachuteDescent both took BuildDeps as `_` and
// returned a bare vertical model, so include_wind was accepted and discarded.
// Only buildPiecewise ever called maybeAddWind. POST /api/v2/prediction with the
// spec's own documented example therefore answered 200 with a trajectory that
// rose and fell on the spot — a wind-free forecast presented as a forecast.
//
// The loop over modelFactories is the part that matters going forward: a newly
// registered model type cannot be added without deciding here what include_wind
// does to it, which is exactly the step that was skipped before.
func TestEveryModelHonoursIncludeWind(t *testing.T) {
cases := map[string]struct {
spec ModelSpec
wantError bool
}{
"constant_rate": {spec: ModelSpec{Type: "constant_rate", Rate: 5, IncludeWind: true}},
"parachute_descent": {spec: ModelSpec{Type: "parachute_descent", SeaLevelRate: 5, IncludeWind: true}},
"piecewise": {spec: ModelSpec{Type: "piecewise", IncludeWind: true,
Segments: []PiecewiseSegmentSpec{{Until: math.Inf(1), Rate: 5}}}},
// include_wind on the wind model itself would sum the wind into itself.
// Refused rather than ignored: a silently doubled wind is a wrong forecast
// that looks right.
"wind": {spec: ModelSpec{Type: "wind", IncludeWind: true}, wantError: true},
}
for name := range modelFactories {
if _, ok := cases[name]; !ok {
t.Errorf("model %q is registered but has no include_wind case here", name)
}
}
deps := BuildDeps{Wind: fixedWind{u: 10, v: -4}}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
built, err := BuildModel(tc.spec, deps)
if tc.wantError {
if err == nil {
t.Fatal("expected include_wind to be refused, got no error")
}
return
}
if err != nil {
t.Fatalf("BuildModel: %v", err)
}
m := built.Model
if built.Build != nil {
m = built.Build(StageContext{})
}
rate := m(0, State{Lat: 0, Lng: 0, Altitude: 1000})
if rate.East != 10 || rate.North != -4 {
t.Errorf("include_wind ignored: east/north = %v/%v, want 10/-4", rate.East, rate.North)
}
})
}
}
// TestIncludeWindWithoutAFieldIsRefused covers the other half of maybeAddWind,
// which returned the wind-free model when deps.Wind was nil. A request that asks
// for wind and cannot get it must fail, not quietly become a vertical drop.
func TestIncludeWindWithoutAFieldIsRefused(t *testing.T) {
for _, typ := range []string{"constant_rate", "parachute_descent", "piecewise"} {
t.Run(typ, func(t *testing.T) {
spec := ModelSpec{Type: typ, Rate: 5, SeaLevelRate: 5, IncludeWind: true,
Segments: []PiecewiseSegmentSpec{{Until: math.Inf(1), Rate: 5}}}
if _, err := BuildModel(spec, BuildDeps{}); err == nil {
t.Error("include_wind with no wind field was accepted")
}
})
}
}