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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ type EventSummary struct {
|
|||
}
|
||||
|
||||
// EventSink collects events from models and the integrator, aggregating
|
||||
// duplicate types into a single EventSummary. Safe for concurrent use.
|
||||
// duplicate types into a single EventSummary. It also carries the run's first
|
||||
// unrecoverable failure, which is a different thing from an event — see Fail.
|
||||
// Safe for concurrent use.
|
||||
type EventSink struct {
|
||||
mu sync.Mutex
|
||||
summaries map[string]*EventSummary
|
||||
err error
|
||||
}
|
||||
|
||||
// NewEventSink returns an empty sink.
|
||||
|
|
@ -61,6 +64,42 @@ func (s *EventSink) Emit(typ string, t float64, state State, message string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Fail records the run's first unrecoverable error. Nil-safe, like Emit.
|
||||
//
|
||||
// Deliberately not an event. An event is an observation the caller may choose to
|
||||
// ignore: "above_model" means samples above the highest pressure level were
|
||||
// extrapolated, which degrades the answer without invalidating it. A failure
|
||||
// means the sampler had no data at all, so the trajectory from that point on is
|
||||
// not a forecast and must not be returned as one.
|
||||
//
|
||||
// Routing both through Emit is how the second came to be treated like the first.
|
||||
// WindTransport discarded the sampler's error and returned a zero rate, which is
|
||||
// indistinguishable from calm air, so any launch past the dataset's horizon
|
||||
// integrated with no wind and produced a balloon that took off and landed on the
|
||||
// same spot — reported as a successful prediction.
|
||||
//
|
||||
// Only the first failure is kept: it is the one that explains the run.
|
||||
func (s *EventSink) Fail(err error) {
|
||||
if s == nil || err == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.err == nil {
|
||||
s.err = err
|
||||
}
|
||||
}
|
||||
|
||||
// Err returns the first failure passed to Fail, or nil if the run was sound.
|
||||
func (s *EventSink) Err() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.err
|
||||
}
|
||||
|
||||
// Snapshot returns a stable copy of every summary in deterministic order
|
||||
// (sorted by Type).
|
||||
func (s *EventSink) Snapshot() []EventSummary {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/numerics"
|
||||
"predictor-refactored/internal/weather"
|
||||
|
|
@ -84,6 +86,11 @@ func WindTransport(field weather.WindField, events *EventSink) Model {
|
|||
return func(t float64, s State) numerics.Rate {
|
||||
sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude)
|
||||
if err != nil {
|
||||
// Recorded, not swallowed. A zero rate here is indistinguishable
|
||||
// from calm air, so returning one turns "we have no data for this
|
||||
// time" into "the balloon did not move" — see EventSink.Fail.
|
||||
events.Fail(fmt.Errorf("no wind data at %s: %w",
|
||||
time.Unix(int64(t), 0).UTC().Format(time.RFC3339), err))
|
||||
return numerics.Rate{}
|
||||
}
|
||||
if sample.AboveModel && events != nil {
|
||||
|
|
|
|||
|
|
@ -205,21 +205,32 @@ func buildPolygon(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
|
|||
return NewPolygon(spec.Vertices, mode, act, spec.Label), nil
|
||||
}
|
||||
|
||||
func buildConstantRate(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
|
||||
return BuiltModel{Model: ConstantRate(spec.Rate)}, nil
|
||||
func buildConstantRate(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
if err := checkWind(spec.IncludeWind, deps); err != nil {
|
||||
return BuiltModel{}, err
|
||||
}
|
||||
return BuiltModel{Model: addWind(ConstantRate(spec.Rate), spec.IncludeWind, deps)}, nil
|
||||
}
|
||||
|
||||
func buildParachuteDescent(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
|
||||
func buildParachuteDescent(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
if spec.SeaLevelRate <= 0 {
|
||||
return BuiltModel{}, fmt.Errorf("parachute_descent requires positive sea_level_rate")
|
||||
}
|
||||
return BuiltModel{Model: ParachuteDescent(spec.SeaLevelRate)}, nil
|
||||
if err := checkWind(spec.IncludeWind, deps); err != nil {
|
||||
return BuiltModel{}, err
|
||||
}
|
||||
return BuiltModel{Model: addWind(ParachuteDescent(spec.SeaLevelRate), spec.IncludeWind, deps)}, nil
|
||||
}
|
||||
|
||||
func buildWind(_ ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
func buildWind(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
if deps.Wind == nil {
|
||||
return BuiltModel{}, fmt.Errorf("wind model requires a loaded wind field")
|
||||
}
|
||||
// Refused rather than ignored: summing the wind into itself would double the
|
||||
// drift, and a doubled wind is a wrong forecast that looks like a right one.
|
||||
if spec.IncludeWind {
|
||||
return BuiltModel{}, fmt.Errorf("wind model does not take include_wind: it would sum the wind into itself")
|
||||
}
|
||||
return BuiltModel{Model: WindTransport(deps.Wind, deps.Events)}, nil
|
||||
}
|
||||
|
||||
|
|
@ -231,12 +242,17 @@ func buildPiecewise(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
|||
return BuiltModel{}, fmt.Errorf("piecewise: unknown segment reference %q", s.Reference)
|
||||
}
|
||||
}
|
||||
// Checked here, not in the closure below: the profile runner calls Build once
|
||||
// per stage and has nowhere to return an error to.
|
||||
if err := checkWind(spec.IncludeWind, deps); err != nil {
|
||||
return BuiltModel{}, err
|
||||
}
|
||||
// Always build lazily: the profile runner supplies a StageContext before
|
||||
// each stage, which is what resolves absolute / profile-relative /
|
||||
// propagator-relative segment times uniformly.
|
||||
return BuiltModel{
|
||||
Build: func(ctx StageContext) Model {
|
||||
return maybeAddWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps)
|
||||
return addWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -266,12 +282,26 @@ func segmentBase(reference string, ctx StageContext) float64 {
|
|||
}
|
||||
}
|
||||
|
||||
// maybeAddWind sums a WindTransport model into base when the spec asks for it.
|
||||
func maybeAddWind(base Model, includeWind bool, deps BuildDeps) Model {
|
||||
if !includeWind {
|
||||
return base
|
||||
// checkWind reports whether include_wind can be satisfied at all.
|
||||
//
|
||||
// A request that asks for wind and cannot have it is an error. This used to
|
||||
// return the wind-free model instead, which turned a missing wind field into a
|
||||
// balloon that rose and fell on the spot, reported as a successful forecast.
|
||||
func checkWind(includeWind bool, deps BuildDeps) error {
|
||||
if includeWind && deps.Wind == nil {
|
||||
return fmt.Errorf("include_wind requires a loaded wind field")
|
||||
}
|
||||
if deps.Wind == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// addWind sums a WindTransport model into base when the spec asks for it.
|
||||
//
|
||||
// Callers must have called checkWind first. The split exists because the
|
||||
// piecewise builder combines inside a closure the profile runner invokes per
|
||||
// stage, with no way to surface an error — so validation happens at build time
|
||||
// and combination stays infallible, rather than an error being swallowed there.
|
||||
func addWind(base Model, includeWind bool, deps BuildDeps) Model {
|
||||
if !includeWind {
|
||||
return base
|
||||
}
|
||||
return Sum(base, WindTransport(deps.Wind, deps.Events))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue