fix(input): correct lon mapping
This commit is contained in:
parent
19557f3a62
commit
84d1664b29
20 changed files with 1197 additions and 137 deletions
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue