package engine import ( "fmt" "math" "strings" "testing" "time" "predictor-refactored/internal/weather" ) // noWind is a WindField that always returns zero wind. type noWind struct{ epoch time.Time } func (n noWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { return weather.Sample{}, nil } func (n noWind) Epoch() time.Time { return n.epoch } func (n noWind) Source() string { return "test" } // flatGround returns 0 metres everywhere. type flatGround struct{} func (flatGround) Elevation(_, _ float64) float64 { return 0 } func TestConstantAscentToBurst(t *testing.T) { burst := 30000.0 rate := 5.0 ascend := &Propagator{ Name: "ascent", Step: 60, Model: Sum(ConstantRate(rate), WindTransport(noWind{}, nil)), Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: burst, On: ActionStop}}, } prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward} results := prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, NewEventSink()) if len(results) != 1 || results[0].Outcome != OutcomeStopped { t.Fatalf("expected one stopped stage, got %+v", results) } if results[0].ConstraintName == "" { t.Errorf("ConstraintName not populated") } if results[0].RefinedState.Altitude == 0 { t.Errorf("RefinedState not populated") } lastT, last := results[0].Path.Last() if math.Abs(last.Altitude-burst) > 5 { t.Errorf("burst altitude = %v, want within 5m of %v", last.Altitude, burst) } wantTime := burst / rate if math.Abs(lastT-wantTime) > 1 { t.Errorf("burst time = %v, want within 1s of %v", lastT, wantTime) } } func TestProfileWithFallback(t *testing.T) { burst := 1000.0 rate := 5.0 descent := &Propagator{ Name: "descent", Step: 60, Model: ParachuteDescent(rate), Constraints: []Constraint{TerrainContact{Provider: flatGround{}, On: ActionStop}}, } ascend := &Propagator{ Name: "ascent", Step: 60, Model: ConstantRate(rate), Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: burst, On: ActionFallback}}, Fallback: descent, } prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward} results := prof.Run(0, State{Altitude: 0}, NewEventSink()) if len(results) != 2 { t.Fatalf("expected 2 results (ascent then descent fallback), got %d", len(results)) } if results[0].Outcome != OutcomeFallback { t.Errorf("first outcome = %v, want OutcomeFallback", results[0].Outcome) } if results[1].Outcome != OutcomeStopped { t.Errorf("second outcome = %v, want OutcomeStopped", results[1].Outcome) } _, last := results[1].Path.Last() if math.Abs(last.Altitude) > 5 { t.Errorf("final altitude = %v, want within 5m of 0", last.Altitude) } } func TestReverseDirection(t *testing.T) { desc := &Propagator{ Name: "rewind", Step: 1, Model: ConstantRate(-1), Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 200, On: ActionStop}}, } prof := Profile{Stages: []*Propagator{desc}, Direction: Reverse} results := prof.Run(0, State{Altitude: 100}, NewEventSink()) lastT, last := results[0].Path.Last() if math.Abs(last.Altitude-200) > 1 { t.Errorf("reverse final altitude = %v, want ~200", last.Altitude) } if lastT >= 0 { t.Errorf("reverse final time = %v, want < 0", lastT) } } func TestPiecewiseRate(t *testing.T) { m := Piecewise([]RateSegment{ {Until: 100, Rate: 5}, {Until: 200, Rate: 3}, {Until: math.Inf(1), Rate: 0}, }) if r := m(50, State{}); r.Vertical != 5 { t.Errorf("rate at t=50 = %v, want 5", r.Vertical) } if r := m(150, State{}); r.Vertical != 3 { t.Errorf("rate at t=150 = %v, want 3", r.Vertical) } if r := m(300, State{}); r.Vertical != 0 { t.Errorf("rate at t=300 = %v, want 0", r.Vertical) } } func TestPiecewiseReferenceResolution(t *testing.T) { // Build via the registry with propagator_start segments. spec := ModelSpec{ Type: "piecewise", Segments: []PiecewiseSegmentSpec{ {Until: 100, Rate: 5, Reference: "propagator_start"}, {Until: 200, Rate: 3, Reference: "propagator_start"}, }, } built, err := BuildModel(spec, BuildDeps{}) if err != nil { t.Fatalf("BuildModel: %v", err) } if built.Build == nil { t.Fatalf("expected lazy build for propagator_start references") } ctx := StageContext{ProfileStart: 1000, PropagatorStart: 5000} m := built.Build(ctx) // Until=100 from propagator_start=5000 → absolute 5100. if r := m(5050, State{}); r.Vertical != 5 { t.Errorf("rate at t=5050 = %v, want 5", r.Vertical) } if r := m(5150, State{}); r.Vertical != 3 { t.Errorf("rate at t=5150 = %v, want 3", r.Vertical) } } // fixedWind returns a constant wind sample. type fixedWind struct{ u, v float64 } func (w fixedWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { return weather.Sample{U: w.u, V: w.v}, nil } func (fixedWind) Epoch() time.Time { return time.Unix(0, 0) } func (fixedWind) Source() string { return "test-fixed" } func TestWindTransportPassesWindThroughUnchanged(t *testing.T) { // The wind field already gives a horizontal velocity, so the propagator // receives it verbatim. This replaces a test of the old deg/s conversion, // whose 1/cos(lat) factor is exactly what made the poles unusable. wind := WindTransport(fixedWind{u: 10, v: -4}, nil) for _, lat := range []float64{0, 45, 60, 89, 89.999} { r := wind(0, State{Lat: lat, Lng: 0, Altitude: 0}) if r.East != 10 || r.North != -4 { t.Errorf("lat %g: rate = %+v, want East=10 North=-4", lat, r) } if r.Vertical != 0 { t.Errorf("lat %g: wind must not produce vertical motion, got %v", lat, r.Vertical) } } } // aboveModelWind reports AboveModel on every sample. Used to verify event emission. type aboveModelWind struct{} func (aboveModelWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { return weather.Sample{AboveModel: true}, nil } func (aboveModelWind) Epoch() time.Time { return time.Unix(0, 0) } func (aboveModelWind) Source() string { return "above" } func TestWindTransportEmitsAboveModel(t *testing.T) { sink := NewEventSink() wind := WindTransport(aboveModelWind{}, sink) for range 3 { _ = wind(0, State{}) } events := sink.Snapshot() if len(events) != 1 || events[0].Type != "above_model" || events[0].Count != 3 { t.Errorf("expected one above_model event with count=3, got %+v", events) } } func TestNoTerminatorStopsAtStepCap(t *testing.T) { // A stage that ascends forever with no constraint must not loop endlessly; // the integrator's step backstop stops it and records a max_steps event. sink := NewEventSink() prof := Profile{ Stages: []*Propagator{{Name: "runaway", Step: 60, Model: ConstantRate(5)}}, Direction: Forward, } results := prof.Run(0, State{}, sink) if results[0].Outcome != OutcomeContinued { t.Errorf("outcome = %v, want OutcomeContinued (step cap)", results[0].Outcome) } if results[0].Path.Len() != DefaultMaxSteps+1 { t.Errorf("path len = %d, want %d", results[0].Path.Len(), DefaultMaxSteps+1) } ev := sink.Snapshot() if len(ev) != 1 || ev[0].Type != "max_steps" { t.Errorf("expected a max_steps event, got %+v", ev) } } func TestPolygonInside(t *testing.T) { // Unit square at the equator. square := []PolygonVertex{ {Lat: -1, Lng: -1}, {Lat: -1, Lng: 1}, {Lat: 1, Lng: 1}, {Lat: 1, Lng: -1}, } c := NewPolygon(square, PolygonInside, ActionStop, "") if !c.Violated(0, State{Lat: 0, Lng: 0}) { t.Errorf("origin should be inside the square") } if c.Violated(0, State{Lat: 5, Lng: 0}) { t.Errorf("(5, 0) should be outside the square") } } func TestPolygonOutsideAntimeridian(t *testing.T) { // A polygon centred near the antimeridian, spanning lng 170..-170 // (i.e. lng 170..190 in [0, 360) form). poly := []PolygonVertex{ {Lat: -10, Lng: 170}, {Lat: -10, Lng: 190}, {Lat: 10, Lng: 190}, {Lat: 10, Lng: 170}, } c := NewPolygon(poly, PolygonInside, ActionStop, "") // A point at the antimeridian. if !c.Violated(0, State{Lat: 0, Lng: 180}) { t.Errorf("(0, 180) should be inside the antimeridian polygon") } if c.Violated(0, State{Lat: 0, Lng: 0}) { 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") } }) } }