176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
package engine
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
"predictor-refactored/internal/weather"
|
||
)
|
||
|
||
// noWind is a WindField that always returns zero wind. Lets us test
|
||
// integration of vertical-only profiles deterministically.
|
||
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{MaxAltitude{Limit: burst, On: ActionStop}},
|
||
}
|
||
|
||
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
|
||
results := prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0})
|
||
|
||
if len(results) != 1 || results[0].Outcome != OutcomeStopped {
|
||
t.Fatalf("expected one stopped stage, got %+v", results)
|
||
}
|
||
|
||
last := results[0].Points[len(results[0].Points)-1]
|
||
// Refinement tolerance is 0.01 in parameter space over a 60s step, so the
|
||
// returned point sits within ±0.6s × rate ≈ ±3m of the boundary.
|
||
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(last.Time-wantTime) > 1 {
|
||
t.Errorf("burst time = %v, want within 1s of %v", last.Time, 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{MaxAltitude{Limit: burst, On: ActionFallback}},
|
||
Fallback: descent,
|
||
}
|
||
|
||
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
|
||
results := prof.Run(0, State{Altitude: 0})
|
||
|
||
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].Points[len(results[1].Points)-1]
|
||
if math.Abs(last.Altitude) > 5 {
|
||
t.Errorf("final altitude = %v, want within 5m of 0", last.Altitude)
|
||
}
|
||
}
|
||
|
||
func TestReverseDirection(t *testing.T) {
|
||
// Start at altitude 100m with downward rate; integrating reverse should
|
||
// give increasing altitude.
|
||
desc := &Propagator{
|
||
Name: "rewind",
|
||
Step: 1,
|
||
Model: ConstantRate(-1), // forward: alt decreases at 1 m/s
|
||
Constraints: []Constraint{MaxAltitude{Limit: 200, On: ActionStop}},
|
||
}
|
||
prof := Profile{Stages: []*Propagator{desc}, Direction: Reverse}
|
||
results := prof.Run(0, State{Altitude: 100})
|
||
|
||
last := results[0].Points[len(results[0].Points)-1]
|
||
if math.Abs(last.Altitude-200) > 1 {
|
||
t.Errorf("reverse final altitude = %v, want ~200", last.Altitude)
|
||
}
|
||
if last.Time >= 0 {
|
||
t.Errorf("reverse final time = %v, want < 0", last.Time)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
// Pure eastward wind of 10 m/s at the equator at sea level.
|
||
// Expected dlng/dt = (180/pi) * 10 / (6371009 * cos(0)) ≈ 0.00008991 deg/s.
|
||
// Expected dlat/dt = 0.
|
||
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)
|
||
}
|
||
|
||
// Pure northward at 60° latitude: dlat = (180/pi) * v / R, dlng = 0.
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestStateAddWrapsLongitude(t *testing.T) {
|
||
// Demonstrates state algebra used by the integrator and refinement.
|
||
s := stateAdd(State{Lat: 0, Lng: 350, Altitude: 0}, 1, State{Lng: 20})
|
||
if math.Abs(s.Lng-10) > 1e-9 {
|
||
t.Errorf("addState wrap: lng = %v, want 10", s.Lng)
|
||
}
|
||
|
||
mid := stateLerp(State{Lng: 350}, State{Lng: 10}, 0.5)
|
||
if math.Abs(mid.Lng-0) > 1e-9 && math.Abs(mid.Lng-360) > 1e-9 {
|
||
t.Errorf("lerpState lng wrap: %v, want 0 or 360", mid.Lng)
|
||
}
|
||
}
|