predictor/internal/engine/engine_test.go
2026-08-04 13:40:50 +09:00

419 lines
14 KiB
Go

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")
}
})
}
}