265 lines
7.9 KiB
Go
265 lines
7.9 KiB
Go
package engine
|
|
|
|
import (
|
|
"math"
|
|
"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.Altitude != 5 {
|
|
t.Errorf("rate at t=50 = %v, want 5", r.Altitude)
|
|
}
|
|
if r := m(150, State{}); r.Altitude != 3 {
|
|
t.Errorf("rate at t=150 = %v, want 3", r.Altitude)
|
|
}
|
|
if r := m(300, State{}); r.Altitude != 0 {
|
|
t.Errorf("rate at t=300 = %v, want 0", r.Altitude)
|
|
}
|
|
}
|
|
|
|
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.Altitude != 5 {
|
|
t.Errorf("rate at t=5050 = %v, want 5", r.Altitude)
|
|
}
|
|
if r := m(5150, State{}); r.Altitude != 3 {
|
|
t.Errorf("rate at t=5150 = %v, want 3", r.Altitude)
|
|
}
|
|
}
|
|
|
|
// 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 TestWindTransportUnitConversion(t *testing.T) {
|
|
wind := WindTransport(fixedWind{u: 10, v: 0}, nil)
|
|
d := wind(0, State{Lat: 0, Lng: 0, Altitude: 0})
|
|
wantLng := (180.0 / math.Pi) * 10.0 / 6371009.0
|
|
if math.Abs(d.Lng-wantLng) > 1e-12 {
|
|
t.Errorf("dlng = %v, want %v", d.Lng, wantLng)
|
|
}
|
|
if math.Abs(d.Lat) > 1e-12 {
|
|
t.Errorf("dlat = %v, want 0 for u=10 v=0", d.Lat)
|
|
}
|
|
|
|
wind2 := WindTransport(fixedWind{u: 0, v: 5}, nil)
|
|
d = wind2(0, State{Lat: 60, Lng: 0, Altitude: 0})
|
|
wantLat := (180.0 / math.Pi) * 5.0 / 6371009.0
|
|
if math.Abs(d.Lat-wantLat) > 1e-12 {
|
|
t.Errorf("dlat at lat=60 = %v, want %v", d.Lat, wantLat)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|