engine refactor
This commit is contained in:
parent
9e663db9dc
commit
81b8e763bd
37 changed files with 3532 additions and 1639 deletions
|
|
@ -1,40 +1,42 @@
|
|||
package engine
|
||||
|
||||
// MaxAltitude triggers when altitude rises above Limit (in metres).
|
||||
// Used as the burst condition for ascent stages.
|
||||
type MaxAltitude struct {
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Altitude triggers when the balloon altitude satisfies Op against Limit.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// Altitude{Op: OpGreaterEqual, Limit: 30000} — burst at 30 km
|
||||
// Altitude{Op: OpLessEqual, Limit: 0} — sea-level descent termination
|
||||
type Altitude struct {
|
||||
Op Operator
|
||||
Limit float64
|
||||
On Action
|
||||
}
|
||||
|
||||
func (c MaxAltitude) Name() string { return "max_altitude" }
|
||||
func (c MaxAltitude) Violated(_ float64, s State) bool { return s.Altitude >= c.Limit }
|
||||
func (c MaxAltitude) Action() Action { return c.On }
|
||||
func (c Altitude) Name() string {
|
||||
return fmt.Sprintf("altitude %s %g", c.Op, c.Limit)
|
||||
}
|
||||
func (c Altitude) Violated(_ float64, s State) bool { return c.Op.Test(s.Altitude, c.Limit) }
|
||||
func (c Altitude) Action() Action { return c.On }
|
||||
|
||||
// MinAltitude triggers when altitude falls at or below Limit (in metres).
|
||||
// With Limit=0 this is the "sea level" terminator.
|
||||
type MinAltitude struct {
|
||||
// Time triggers when the integration time t (UNIX seconds) satisfies Op
|
||||
// against Limit.
|
||||
type Time struct {
|
||||
Op Operator
|
||||
Limit float64
|
||||
On Action
|
||||
}
|
||||
|
||||
func (c MinAltitude) Name() string { return "min_altitude" }
|
||||
func (c MinAltitude) Violated(_ float64, s State) bool { return s.Altitude <= c.Limit }
|
||||
func (c MinAltitude) Action() Action { return c.On }
|
||||
func (c Time) Name() string { return fmt.Sprintf("time %s %g", c.Op, c.Limit) }
|
||||
func (c Time) Violated(t float64, _ State) bool { return c.Op.Test(t, c.Limit) }
|
||||
func (c Time) Action() Action { return c.On }
|
||||
|
||||
// MaxTime triggers when t exceeds Limit (UNIX seconds). Used as a stop
|
||||
// condition for float profiles.
|
||||
type MaxTime struct {
|
||||
Limit float64
|
||||
On Action
|
||||
}
|
||||
|
||||
func (c MaxTime) Name() string { return "max_time" }
|
||||
func (c MaxTime) Violated(t float64, _ State) bool { return t > c.Limit }
|
||||
func (c MaxTime) Action() Action { return c.On }
|
||||
|
||||
// TerrainContact triggers when altitude has dropped at or below ground level.
|
||||
// Equivalent to Tawhiri's elevation termination.
|
||||
// TerrainContact triggers when the ground elevation exceeds the balloon's
|
||||
// altitude — i.e. the balloon has hit the ground.
|
||||
type TerrainContact struct {
|
||||
Provider TerrainProvider
|
||||
On Action
|
||||
|
|
@ -45,3 +47,103 @@ func (c TerrainContact) Violated(_ float64, s State) bool {
|
|||
return c.Provider.Elevation(s.Lat, s.Lng) > s.Altitude
|
||||
}
|
||||
func (c TerrainContact) Action() Action { return c.On }
|
||||
|
||||
// PolygonMode selects whether Polygon fires when the balloon is inside or
|
||||
// outside the configured polygon.
|
||||
type PolygonMode int
|
||||
|
||||
const (
|
||||
// PolygonInside fires when (lat, lng) lies inside the polygon — useful
|
||||
// for "must not enter restricted airspace".
|
||||
PolygonInside PolygonMode = iota
|
||||
// PolygonOutside fires when (lat, lng) lies outside the polygon —
|
||||
// useful for "must remain over the test range".
|
||||
PolygonOutside
|
||||
)
|
||||
|
||||
// PolygonVertex is one vertex of a geographic polygon. Latitudes are in
|
||||
// degrees [-90, 90]; longitudes in degrees [0, 360) or [-180, 180]
|
||||
// (callers normalise — see Polygon.Violated).
|
||||
type PolygonVertex struct {
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
|
||||
// Polygon is a constraint over a geographic polygon. The polygon is
|
||||
// considered closed (last vertex connects to the first) and is interpreted
|
||||
// in plate-carrée (rectangular lat/lng) coordinates with longitude
|
||||
// wrap-around handling.
|
||||
//
|
||||
// Edges crossing the 180/-180 antimeridian are split via longitude
|
||||
// normalisation against the polygon's centroid: callers that need
|
||||
// great-circle accuracy should clip their polygon along the antimeridian
|
||||
// before submitting.
|
||||
type Polygon struct {
|
||||
Vertices []PolygonVertex
|
||||
Mode PolygonMode
|
||||
On Action
|
||||
|
||||
// Label, if set, is returned by Name. Defaults to "polygon_inside" or
|
||||
// "polygon_outside" based on Mode.
|
||||
Label string
|
||||
}
|
||||
|
||||
func (c Polygon) Name() string {
|
||||
if c.Label != "" {
|
||||
return c.Label
|
||||
}
|
||||
if c.Mode == PolygonOutside {
|
||||
return "polygon_outside"
|
||||
}
|
||||
return "polygon_inside"
|
||||
}
|
||||
func (c Polygon) Action() Action { return c.On }
|
||||
|
||||
// Violated reports whether the state satisfies the polygon-containment rule.
|
||||
func (c Polygon) Violated(_ float64, s State) bool {
|
||||
if len(c.Vertices) < 3 {
|
||||
return false
|
||||
}
|
||||
in := pointInPolygon(s.Lat, s.Lng, c.Vertices)
|
||||
if c.Mode == PolygonInside {
|
||||
return in
|
||||
}
|
||||
return !in
|
||||
}
|
||||
|
||||
// pointInPolygon implements the ray-casting algorithm in lat/lng space.
|
||||
//
|
||||
// All vertices and the query point are normalised to within 180° of
|
||||
// verts[0] before testing, so a polygon spanning the antimeridian is
|
||||
// handled correctly as long as the polygon itself spans no more than 180°
|
||||
// in longitude.
|
||||
func pointInPolygon(lat, lng float64, verts []PolygonVertex) bool {
|
||||
if len(verts) == 0 {
|
||||
return false
|
||||
}
|
||||
ref := verts[0].Lng
|
||||
qx := normLng(lng, ref)
|
||||
|
||||
inside := false
|
||||
n := len(verts)
|
||||
for i, j := 0, n-1; i < n; j, i = i, i+1 {
|
||||
yi, yj := verts[i].Lat, verts[j].Lat
|
||||
xi := normLng(verts[i].Lng, ref)
|
||||
xj := normLng(verts[j].Lng, ref)
|
||||
|
||||
if (yi > lat) != (yj > lat) {
|
||||
xIntersect := (xj-xi)*(lat-yi)/(yj-yi) + xi
|
||||
if qx < xIntersect {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// normLng rewrites v so that it lies within 180° of ref. With ref=10 and
|
||||
// v=350, normLng returns -10.
|
||||
func normLng(v, ref float64) float64 {
|
||||
diff := math.Mod(v-ref+540, 360) - 180
|
||||
return ref + diff
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import (
|
|||
"predictor-refactored/internal/weather"
|
||||
)
|
||||
|
||||
// noWind is a WindField that always returns zero wind. Lets us test
|
||||
// integration of vertical-only profiles deterministically.
|
||||
// noWind is a WindField that always returns zero wind.
|
||||
type noWind struct{ epoch time.Time }
|
||||
|
||||
func (n noWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) {
|
||||
|
|
@ -31,19 +30,23 @@ func TestConstantAscentToBurst(t *testing.T) {
|
|||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: Sum(ConstantRate(rate), WindTransport(noWind{}, nil)),
|
||||
Constraints: []Constraint{MaxAltitude{Limit: burst, On: ActionStop}},
|
||||
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})
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -67,12 +70,12 @@ func TestProfileWithFallback(t *testing.T) {
|
|||
Name: "ascent",
|
||||
Step: 60,
|
||||
Model: ConstantRate(rate),
|
||||
Constraints: []Constraint{MaxAltitude{Limit: burst, On: ActionFallback}},
|
||||
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})
|
||||
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))
|
||||
|
|
@ -91,16 +94,14 @@ func TestProfileWithFallback(t *testing.T) {
|
|||
}
|
||||
|
||||
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}},
|
||||
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})
|
||||
results := prof.Run(0, State{Altitude: 100}, NewEventSink())
|
||||
|
||||
last := results[0].Points[len(results[0].Points)-1]
|
||||
if math.Abs(last.Altitude-200) > 1 {
|
||||
|
|
@ -129,6 +130,33 @@ func TestPiecewiseRate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
|
|
@ -139,12 +167,8 @@ 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)
|
||||
|
|
@ -153,7 +177,6 @@ func TestWindTransportUnitConversion(t *testing.T) {
|
|||
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
|
||||
|
|
@ -162,8 +185,28 @@ func TestWindTransportUnitConversion(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
|
|
@ -174,3 +217,39 @@ func TestStateAddWrapsLongitude(t *testing.T) {
|
|||
t.Errorf("lerpState lng wrap: %v, want 0 or 360", mid.Lng)
|
||||
}
|
||||
}
|
||||
|
||||
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 := Polygon{Vertices: square, Mode: PolygonInside, On: 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 := Polygon{Vertices: poly, Mode: PolygonInside, On: 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
internal/engine/events.go
Normal file
89
internal/engine/events.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package engine
|
||||
|
||||
import "sync"
|
||||
|
||||
// Event is a non-fatal observation made during integration.
|
||||
//
|
||||
// Events generalise the warnings counter from the original Tawhiri port:
|
||||
// any model or constraint can emit them, the EventSink aggregates by Type,
|
||||
// and each Result carries a summary slice for the API to surface.
|
||||
type Event struct {
|
||||
Type string // short identifier, e.g. "above_model"
|
||||
Time float64 // UNIX seconds when the event was emitted
|
||||
State State
|
||||
Message string
|
||||
}
|
||||
|
||||
// EventSummary is the per-type aggregation of repeated emissions.
|
||||
type EventSummary struct {
|
||||
Type string `json:"type"`
|
||||
Count int64 `json:"count"`
|
||||
FirstTime float64 `json:"first_time"`
|
||||
LastTime float64 `json:"last_time"`
|
||||
FirstState State `json:"first_state"`
|
||||
LastState State `json:"last_state"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// EventSink collects events from models and the integrator, aggregating
|
||||
// duplicate types into a single EventSummary. Safe for concurrent use.
|
||||
type EventSink struct {
|
||||
mu sync.Mutex
|
||||
summaries map[string]*EventSummary
|
||||
}
|
||||
|
||||
// NewEventSink returns an empty sink.
|
||||
func NewEventSink() *EventSink { return &EventSink{summaries: make(map[string]*EventSummary)} }
|
||||
|
||||
// Emit records one occurrence of typ at (t, s) with the provided message.
|
||||
// Subsequent emits with the same typ update LastTime/LastState and Count.
|
||||
func (s *EventSink) Emit(typ string, t float64, state State, message string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sum, ok := s.summaries[typ]
|
||||
if !ok {
|
||||
s.summaries[typ] = &EventSummary{
|
||||
Type: typ, Count: 1,
|
||||
FirstTime: t, LastTime: t,
|
||||
FirstState: state, LastState: state,
|
||||
Message: message,
|
||||
}
|
||||
return
|
||||
}
|
||||
sum.Count++
|
||||
sum.LastTime = t
|
||||
sum.LastState = state
|
||||
if sum.Message == "" && message != "" {
|
||||
sum.Message = message
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a stable copy of every summary in deterministic order
|
||||
// (sorted by Type).
|
||||
func (s *EventSink) Snapshot() []EventSummary {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]EventSummary, 0, len(s.summaries))
|
||||
for _, sum := range s.summaries {
|
||||
out = append(out, *sum)
|
||||
}
|
||||
sortEventSummaries(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortEventSummaries(s []EventSummary) {
|
||||
// Insertion sort: usually one or two entries.
|
||||
for i := 1; i < len(s); i++ {
|
||||
j := i
|
||||
for j > 0 && s[j-1].Type > s[j].Type {
|
||||
s[j-1], s[j] = s[j], s[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,13 @@ package engine
|
|||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
|
||||
"predictor-refactored/internal/weather"
|
||||
)
|
||||
|
||||
// Sum composes models by summing their derivatives at each evaluation point.
|
||||
//
|
||||
// Useful for combining e.g. a vertical-rate model with a horizontal wind model
|
||||
// Useful for combining a vertical-rate model with a horizontal wind model
|
||||
// into a single propagator. Equivalent to Tawhiri's LinearModel.
|
||||
func Sum(models ...Model) Model {
|
||||
if len(models) == 1 {
|
||||
|
|
@ -29,18 +28,16 @@ func Sum(models ...Model) Model {
|
|||
}
|
||||
|
||||
// ConstantRate returns a model with a constant vertical velocity (m/s).
|
||||
// A positive rate is upward (ascent); a negative rate is downward.
|
||||
// Positive rates are upward.
|
||||
func ConstantRate(rate float64) Model {
|
||||
return func(_ float64, _ State) State {
|
||||
return State{Altitude: rate}
|
||||
}
|
||||
return func(_ float64, _ State) State { return State{Altitude: rate} }
|
||||
}
|
||||
|
||||
// ParachuteDescent returns a model where vertical velocity grows with altitude
|
||||
// because thinner air provides less drag.
|
||||
// ParachuteDescent returns a model where vertical velocity grows with
|
||||
// altitude because thinner air provides less drag. seaLevelRate is the
|
||||
// descent speed at sea level (m/s, positive).
|
||||
//
|
||||
// seaLevelRate is the descent speed at sea level (m/s, positive number).
|
||||
// The terminal velocity at altitude is computed as
|
||||
// Terminal velocity at altitude is computed as
|
||||
//
|
||||
// v = -k / sqrt(rho(alt)), k = seaLevelRate * 1.1045,
|
||||
//
|
||||
|
|
@ -52,9 +49,9 @@ func ParachuteDescent(seaLevelRate float64) Model {
|
|||
}
|
||||
}
|
||||
|
||||
// nasaDensity returns air density (kg/m^3) for the given altitude in metres,
|
||||
// using the NASA simple atmosphere model. See
|
||||
// https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html.
|
||||
// nasaDensity returns air density (kg/m^3) for an altitude in metres,
|
||||
// using the NASA simple atmosphere model.
|
||||
// See https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html.
|
||||
func nasaDensity(alt float64) float64 {
|
||||
var temp, pressure float64
|
||||
switch {
|
||||
|
|
@ -71,22 +68,17 @@ func nasaDensity(alt float64) float64 {
|
|||
return pressure / (0.2869 * (temp + 273.1))
|
||||
}
|
||||
|
||||
// RateSegment is one entry in a Piecewise rate schedule.
|
||||
// RateSegment is one entry in a Piecewise rate schedule. Until is the UNIX
|
||||
// timestamp at which this segment ends — the model emits the segment's
|
||||
// Rate for all t < Until. The final segment's Rate is held indefinitely.
|
||||
type RateSegment struct {
|
||||
// Until is the UNIX timestamp at which this segment ends.
|
||||
// The model applies the segment's Rate for all t < Until.
|
||||
Until float64
|
||||
// Rate is the vertical velocity (m/s) during the segment. Positive is up.
|
||||
Rate float64
|
||||
Rate float64
|
||||
}
|
||||
|
||||
// Piecewise returns a model that produces a piecewise-constant vertical rate
|
||||
// over a sequence of time intervals.
|
||||
//
|
||||
// Segments are searched by their Until field; the first segment whose Until
|
||||
// exceeds t supplies the active rate. For t at or after the last Until, the
|
||||
// final segment's Rate is held indefinitely. Input is sorted ascending by
|
||||
// Until on construction.
|
||||
// Piecewise returns a model that produces a piecewise-constant vertical
|
||||
// rate over a sequence of intervals. The input is sorted ascending by
|
||||
// Until on construction; later segments shadow earlier ones.
|
||||
func Piecewise(segments []RateSegment) Model {
|
||||
if len(segments) == 0 {
|
||||
return ConstantRate(0)
|
||||
|
|
@ -104,33 +96,13 @@ func Piecewise(segments []RateSegment) Model {
|
|||
}
|
||||
}
|
||||
|
||||
// Warnings aggregates non-fatal conditions encountered during integration.
|
||||
type Warnings struct {
|
||||
// AltitudeTooHigh counts evaluations where the wind sampler reported
|
||||
// that altitude was above the highest pressure level of the dataset.
|
||||
AltitudeTooHigh atomic.Int64
|
||||
}
|
||||
|
||||
// ToMap returns warnings as a map suitable for JSON output. Only counters
|
||||
// that have fired are included.
|
||||
func (w *Warnings) ToMap() map[string]any {
|
||||
out := make(map[string]any)
|
||||
if n := w.AltitudeTooHigh.Load(); n > 0 {
|
||||
out["altitude_too_high"] = map[string]any{
|
||||
"count": n,
|
||||
"description": "altitude exceeded the highest pressure level of the wind dataset; samples were extrapolated",
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WindTransport returns a model that moves laterally at the wind velocity
|
||||
// sampled from field. The vertical component of the returned derivative is
|
||||
// zero. Wind units are converted from m/s to deg/s on Earth's surface.
|
||||
// sampled from field. Vertical component is zero. Wind components in m/s
|
||||
// are converted to deg/s on Earth's surface using R = 6371009 m.
|
||||
//
|
||||
// If warnings is non-nil, the AltitudeTooHigh counter is incremented for any
|
||||
// sample where the wind field reported altitude above the model top.
|
||||
func WindTransport(field weather.WindField, warnings *Warnings) Model {
|
||||
// If events is non-nil, an "above_model" event is emitted whenever the
|
||||
// wind field reports altitude above the highest pressure level.
|
||||
func WindTransport(field weather.WindField, events *EventSink) Model {
|
||||
const earthR = 6371009.0
|
||||
const piOver180 = math.Pi / 180.0
|
||||
const degPerRad = 180.0 / math.Pi
|
||||
|
|
@ -139,8 +111,9 @@ func WindTransport(field weather.WindField, warnings *Warnings) Model {
|
|||
if err != nil {
|
||||
return State{}
|
||||
}
|
||||
if sample.AboveModel && warnings != nil {
|
||||
warnings.AltitudeTooHigh.Add(1)
|
||||
if sample.AboveModel && events != nil {
|
||||
events.Emit("above_model", t, s,
|
||||
"altitude exceeded the highest pressure level of the wind dataset; samples extrapolated")
|
||||
}
|
||||
r := earthR + s.Altitude
|
||||
return State{
|
||||
|
|
|
|||
69
internal/engine/operators.go
Normal file
69
internal/engine/operators.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package engine
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Operator is a scalar comparison used by generalised constraints like
|
||||
// Altitude and Time. A constraint fires when its Operator.Test(value, limit)
|
||||
// returns true.
|
||||
type Operator int
|
||||
|
||||
const (
|
||||
OpLess Operator = iota // value < limit
|
||||
OpLessEqual // value ≤ limit
|
||||
OpGreater // value > limit
|
||||
OpGreaterEqual // value ≥ limit
|
||||
OpEqual // value == limit
|
||||
)
|
||||
|
||||
// Test evaluates op(value, limit).
|
||||
func (o Operator) Test(value, limit float64) bool {
|
||||
switch o {
|
||||
case OpLess:
|
||||
return value < limit
|
||||
case OpLessEqual:
|
||||
return value <= limit
|
||||
case OpGreater:
|
||||
return value > limit
|
||||
case OpGreaterEqual:
|
||||
return value >= limit
|
||||
case OpEqual:
|
||||
return value == limit
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String returns the symbol "<", "<=", ">", ">=", "==".
|
||||
func (o Operator) String() string {
|
||||
switch o {
|
||||
case OpLess:
|
||||
return "<"
|
||||
case OpLessEqual:
|
||||
return "<="
|
||||
case OpGreater:
|
||||
return ">"
|
||||
case OpGreaterEqual:
|
||||
return ">="
|
||||
case OpEqual:
|
||||
return "=="
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ParseOperator maps a textual operator to its Operator constant.
|
||||
// Accepts "<", "<=", "le", ">", ">=", "ge", "==", "eq".
|
||||
func ParseOperator(s string) (Operator, error) {
|
||||
switch s {
|
||||
case "<", "lt":
|
||||
return OpLess, nil
|
||||
case "<=", "le":
|
||||
return OpLessEqual, nil
|
||||
case ">", "gt":
|
||||
return OpGreater, nil
|
||||
case ">=", "ge":
|
||||
return OpGreaterEqual, nil
|
||||
case "==", "eq":
|
||||
return OpEqual, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown operator %q", s)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,26 @@ package engine
|
|||
// Profile is an ordered chain of propagators executed sequentially. Each
|
||||
// propagator picks up where the previous one finished.
|
||||
type Profile struct {
|
||||
// Stages are run in order. For Direction=Reverse they are still iterated
|
||||
// from index 0 onwards, but each propagator integrates with negative dt.
|
||||
// Stages are run in order. For Direction=Reverse they are still
|
||||
// iterated from index 0 onwards but each propagator integrates with
|
||||
// negative dt.
|
||||
Stages []*Propagator
|
||||
|
||||
// Direction controls the sign of dt across the whole profile.
|
||||
// Direction controls the sign of dt across the profile.
|
||||
Direction Direction
|
||||
|
||||
// Globals are constraints evaluated alongside each stage's local Constraints.
|
||||
// Useful for profile-wide bounds like "stop after N hours total".
|
||||
// Globals are constraints evaluated alongside each stage's local
|
||||
// Constraints. Useful for profile-wide bounds like "stop after N hours".
|
||||
Globals []Constraint
|
||||
}
|
||||
|
||||
// Run executes the profile from the given launch point. Returns one Result
|
||||
// per executed stage, including any Fallback chains that were activated.
|
||||
func (p *Profile) Run(t0 float64, launch State) []Result {
|
||||
// Run executes the profile from the given launch point. Returns one
|
||||
// Result per executed stage, including any Fallback chains that were
|
||||
// activated. The supplied EventSink is shared across stages and aggregates
|
||||
// non-fatal observations.
|
||||
//
|
||||
// events may be nil; pass NewEventSink() to capture observations.
|
||||
func (p *Profile) Run(t0 float64, launch State, events *EventSink) []Result {
|
||||
if p.Direction == 0 {
|
||||
p.Direction = Forward
|
||||
}
|
||||
|
|
@ -27,28 +32,36 @@ func (p *Profile) Run(t0 float64, launch State) []Result {
|
|||
|
||||
for i := 0; i < len(p.Stages); i++ {
|
||||
stage := p.Stages[i]
|
||||
res := stage.run(t, s, p.Direction, p.Globals)
|
||||
ctx := StageContext{
|
||||
ProfileStart: t0,
|
||||
PropagatorStart: t,
|
||||
Launch: launch,
|
||||
PropagatorState: s,
|
||||
Direction: p.Direction,
|
||||
}
|
||||
res := stage.run(ctx, t, s, p.Globals, events)
|
||||
results = append(results, res)
|
||||
|
||||
last := res.Points[len(res.Points)-1]
|
||||
t = last.Time
|
||||
s = State{Lat: last.Lat, Lng: last.Lng, Altitude: last.Altitude}
|
||||
|
||||
// Follow Fallback chains until none remains. Each fallback consumes
|
||||
// from the same point the previous stage stopped at.
|
||||
// Follow Fallback chains until none remains.
|
||||
for res.Outcome == OutcomeFallback && stage.Fallback != nil {
|
||||
stage = stage.Fallback
|
||||
res = stage.run(t, s, p.Direction, p.Globals)
|
||||
ctx = StageContext{
|
||||
ProfileStart: t0,
|
||||
PropagatorStart: t,
|
||||
Launch: launch,
|
||||
PropagatorState: s,
|
||||
Direction: p.Direction,
|
||||
}
|
||||
res = stage.run(ctx, t, s, p.Globals, events)
|
||||
results = append(results, res)
|
||||
last = res.Points[len(res.Points)-1]
|
||||
t = last.Time
|
||||
s = State{Lat: last.Lat, Lng: last.Lng, Altitude: last.Altitude}
|
||||
}
|
||||
|
||||
// If a propagator's stop fired (not a fallback), end the profile.
|
||||
if res.Outcome == OutcomeStopped {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -7,71 +7,58 @@ import (
|
|||
// Propagator advances state under one Model, checking a set of Constraints
|
||||
// after every integration step.
|
||||
//
|
||||
// When a constraint fires, the propagator binary-search refines the violation
|
||||
// point and emits it as its final trajectory point. The Action of the
|
||||
// triggering constraint controls what the surrounding Profile does next:
|
||||
// stop the profile, transfer to Fallback, or clip and continue.
|
||||
// When a constraint fires, the propagator binary-search refines the
|
||||
// violation point and emits it as its final trajectory point. The Action of
|
||||
// the triggering constraint controls what the surrounding Profile does
|
||||
// next: stop the profile, transfer to Fallback, or clip and continue.
|
||||
type Propagator struct {
|
||||
// Name identifies the propagator in trajectory metadata.
|
||||
// Name identifies the propagator in trajectory metadata. Optional —
|
||||
// callers using sequential profile chains may leave it empty.
|
||||
Name string
|
||||
|
||||
// Step is the magnitude of the integration step in seconds (always positive).
|
||||
// The Profile flips its sign for Reverse direction.
|
||||
Step float64
|
||||
|
||||
// Model produces the per-second time derivative of state.
|
||||
Model Model
|
||||
// Model is the per-second derivative function used for integration.
|
||||
// One of Model or BuildModel must be non-nil. If both are set, BuildModel
|
||||
// takes precedence (it is invoked once per stage with a StageContext).
|
||||
Model Model
|
||||
BuildModel func(ctx StageContext) Model
|
||||
|
||||
// Constraints are evaluated after each step. Any fired constraint stops
|
||||
// the propagator at the refined point; the first one in this slice wins
|
||||
// on ties.
|
||||
Constraints []Constraint
|
||||
// Constraints are evaluated after each step. The first violation wins.
|
||||
Constraints []Constraint
|
||||
BuildConstraints func(ctx StageContext) []Constraint
|
||||
|
||||
// Fallback is the propagator to switch to when a constraint with
|
||||
// ActionFallback fires. Optional.
|
||||
Fallback *Propagator
|
||||
|
||||
// Tolerance is the binary-search refinement tolerance in parameter space
|
||||
// (default 0.01, matching Tawhiri).
|
||||
// Tolerance is the binary-search refinement tolerance in parameter
|
||||
// space (default 0.01, matching Tawhiri).
|
||||
Tolerance float64
|
||||
}
|
||||
|
||||
// Outcome describes how a propagator's run ended.
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
// OutcomeStopped means a Constraint with ActionStop fired and the profile
|
||||
// should end here.
|
||||
OutcomeStopped Outcome = iota
|
||||
// OutcomeFallback means a Constraint with ActionFallback fired and the
|
||||
// profile should transfer to the propagator's Fallback chain.
|
||||
OutcomeFallback
|
||||
// OutcomeContinued means no constraint fired before the time horizon was
|
||||
// reached. In practice this is only seen when a propagator runs unbounded,
|
||||
// which means the profile is misconfigured.
|
||||
OutcomeContinued
|
||||
)
|
||||
|
||||
// Result is the output of running one propagator.
|
||||
type Result struct {
|
||||
Propagator string
|
||||
Points []TrajectoryPoint
|
||||
Outcome Outcome
|
||||
// Constraint is the constraint that fired, or nil if Outcome == OutcomeContinued.
|
||||
Constraint Constraint
|
||||
}
|
||||
|
||||
// run integrates the model from (t0, s0) in direction dir, returning a Result.
|
||||
// globals are constraints injected by the Profile and checked alongside the
|
||||
// propagator's local Constraints.
|
||||
func (p *Propagator) run(t0 float64, s0 State, dir Direction, globals []Constraint) Result {
|
||||
dt := p.Step * float64(dir)
|
||||
// propagator's local Constraints. events receives non-fatal observations.
|
||||
func (p *Propagator) run(ctx StageContext, t0 float64, s0 State, globals []Constraint, events *EventSink) Result {
|
||||
dt := p.Step * float64(ctx.Direction)
|
||||
tol := p.Tolerance
|
||||
if tol == 0 {
|
||||
tol = 0.01
|
||||
}
|
||||
|
||||
deriv := numerics.Deriv[State](func(t float64, s State) State { return p.Model(t, s) })
|
||||
model := p.Model
|
||||
if p.BuildModel != nil {
|
||||
model = p.BuildModel(ctx)
|
||||
}
|
||||
constraints := p.Constraints
|
||||
if p.BuildConstraints != nil {
|
||||
constraints = p.BuildConstraints(ctx)
|
||||
}
|
||||
|
||||
deriv := numerics.Deriv[State](func(t float64, s State) State { return model(t, s) })
|
||||
add := numerics.VecAdd[State](stateAdd)
|
||||
lerp := numerics.VecLerp[State](stateLerp)
|
||||
|
||||
|
|
@ -90,39 +77,50 @@ func (p *Propagator) run(t0 float64, s0 State, dir Direction, globals []Constrai
|
|||
s2 := numerics.RK4Step(t, s, dt, deriv, add)
|
||||
t2 := t + dt
|
||||
|
||||
if c, fired := firstFiring(p.Constraints, globals, t2, s2); fired {
|
||||
trig := numerics.Trigger[State](func(tt float64, ss State) bool { return c.Violated(tt, ss) })
|
||||
t3, s3 := numerics.RefineTrigger(t, s, t2, s2, trig, lerp, tol)
|
||||
|
||||
switch c.Action() {
|
||||
case ActionClip:
|
||||
s3 = clipToConstraint(c, s3)
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
t, s = t3, s3
|
||||
continue
|
||||
case ActionFallback:
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
out.Outcome = OutcomeFallback
|
||||
out.Constraint = c
|
||||
return out
|
||||
default: // ActionStop
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
out.Outcome = OutcomeStopped
|
||||
out.Constraint = c
|
||||
return out
|
||||
}
|
||||
c, fired := firstFiring(constraints, globals, t2, s2)
|
||||
if !fired {
|
||||
t, s = t2, s2
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t, Lat: s.Lat, Lng: s.Lng, Altitude: s.Altitude,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
t, s = t2, s2
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t, Lat: s.Lat, Lng: s.Lng, Altitude: s.Altitude,
|
||||
})
|
||||
// Record the unrefined violation.
|
||||
out.ViolationTime = t2
|
||||
out.ViolationState = s2
|
||||
|
||||
trig := numerics.Trigger[State](func(tt float64, ss State) bool { return c.Violated(tt, ss) })
|
||||
t3, s3 := numerics.RefineTrigger(t, s, t2, s2, trig, lerp, tol)
|
||||
out.RefinedTime = t3
|
||||
out.RefinedState = s3
|
||||
out.Constraint = c
|
||||
out.ConstraintName = c.Name()
|
||||
|
||||
switch c.Action() {
|
||||
case ActionClip:
|
||||
s3 = clipToConstraint(c, s3)
|
||||
out.RefinedState = s3
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
t, s = t3, s3
|
||||
continue
|
||||
case ActionFallback:
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
out.Outcome = OutcomeFallback
|
||||
out.Events = events.Snapshot()
|
||||
return out
|
||||
default: // ActionStop
|
||||
out.Points = append(out.Points, TrajectoryPoint{
|
||||
Time: t3, Lat: s3.Lat, Lng: s3.Lng, Altitude: s3.Altitude,
|
||||
})
|
||||
out.Outcome = OutcomeStopped
|
||||
out.Events = events.Snapshot()
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,15 +140,12 @@ func firstFiring(local, globals []Constraint, t float64, s State) (Constraint, b
|
|||
return nil, false
|
||||
}
|
||||
|
||||
// clipToConstraint adjusts s so that the given constraint is exactly satisfied
|
||||
// (not violated). Implemented for constraints with a well-defined boundary;
|
||||
// others fall through unchanged.
|
||||
// clipToConstraint adjusts s so that the given constraint is exactly
|
||||
// satisfied (not violated). Defined only for constraints with a
|
||||
// well-defined coordinate boundary; others fall through unchanged.
|
||||
func clipToConstraint(c Constraint, s State) State {
|
||||
switch v := c.(type) {
|
||||
case MaxAltitude:
|
||||
s.Altitude = v.Limit
|
||||
case MinAltitude:
|
||||
s.Altitude = v.Limit
|
||||
if alt, ok := c.(Altitude); ok {
|
||||
s.Altitude = alt.Limit
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
287
internal/engine/registry.go
Normal file
287
internal/engine/registry.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"predictor-refactored/internal/weather"
|
||||
)
|
||||
|
||||
// ConstraintSpec is the source-agnostic JSON-shape used to declare a
|
||||
// constraint. The Type field is the registry key; remaining fields are
|
||||
// extracted by the registered factory.
|
||||
type ConstraintSpec struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action,omitempty"`
|
||||
// Op is the comparison operator for scalar constraints (altitude, time).
|
||||
Op string `json:"op,omitempty"`
|
||||
Limit float64 `json:"limit,omitempty"`
|
||||
// Vertices and Mode are used by the polygon constraint.
|
||||
Vertices []PolygonVertex `json:"vertices,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Label is an optional human-readable identifier surfaced via Name().
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// ModelSpec is the source-agnostic JSON shape used to declare a model.
|
||||
type ModelSpec struct {
|
||||
Type string `json:"type"`
|
||||
// Rate (m/s) for constant_rate.
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
// SeaLevelRate (m/s, positive) for parachute_descent.
|
||||
SeaLevelRate float64 `json:"sea_level_rate,omitempty"`
|
||||
// Segments for piecewise.
|
||||
Segments []PiecewiseSegmentSpec `json:"segments,omitempty"`
|
||||
// IncludeWind sums a WindTransport model into the resulting derivative.
|
||||
IncludeWind bool `json:"include_wind,omitempty"`
|
||||
}
|
||||
|
||||
// PiecewiseSegmentSpec is one entry in a piecewise rate schedule.
|
||||
//
|
||||
// Reference selects how the Until field is interpreted:
|
||||
//
|
||||
// - "absolute" (default): UNIX seconds.
|
||||
// - "profile_start": seconds since the profile's launch time.
|
||||
// - "propagator_start": seconds since this propagator began running.
|
||||
type PiecewiseSegmentSpec struct {
|
||||
Until float64 `json:"until"`
|
||||
Rate float64 `json:"rate"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
}
|
||||
|
||||
// BuildDeps bundle the runtime dependencies factories may consult.
|
||||
type BuildDeps struct {
|
||||
Wind weather.WindField
|
||||
Terrain TerrainProvider
|
||||
Events *EventSink
|
||||
}
|
||||
|
||||
// ConstraintFactory builds one Constraint from a spec.
|
||||
type ConstraintFactory func(spec ConstraintSpec, deps BuildDeps) (Constraint, error)
|
||||
|
||||
// ModelFactory builds one model from a spec. The returned Built is held by
|
||||
// a Propagator; if Build is set, it is invoked lazily by the profile
|
||||
// runner before every stage so it can capture per-stage start times.
|
||||
type ModelFactory func(spec ModelSpec, deps BuildDeps) (BuiltModel, error)
|
||||
|
||||
// BuiltModel is either an eager Model, a lazy Build, or both. The profile
|
||||
// runner prefers Build when present.
|
||||
type BuiltModel struct {
|
||||
Model Model
|
||||
Build func(ctx StageContext) Model
|
||||
}
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
constraintFactories = map[string]ConstraintFactory{}
|
||||
modelFactories = map[string]ModelFactory{}
|
||||
)
|
||||
|
||||
// RegisterConstraint installs a factory for typeName. Subsequent calls
|
||||
// overwrite the previous factory.
|
||||
func RegisterConstraint(typeName string, f ConstraintFactory) {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
constraintFactories[typeName] = f
|
||||
}
|
||||
|
||||
// RegisterModel installs a model factory.
|
||||
func RegisterModel(typeName string, f ModelFactory) {
|
||||
regMu.Lock()
|
||||
defer regMu.Unlock()
|
||||
modelFactories[typeName] = f
|
||||
}
|
||||
|
||||
// BuildConstraint dispatches spec to its registered factory.
|
||||
func BuildConstraint(spec ConstraintSpec, deps BuildDeps) (Constraint, error) {
|
||||
regMu.RLock()
|
||||
f, ok := constraintFactories[spec.Type]
|
||||
regMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown constraint type %q", spec.Type)
|
||||
}
|
||||
return f(spec, deps)
|
||||
}
|
||||
|
||||
// BuildModel dispatches spec to its registered factory.
|
||||
func BuildModel(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
regMu.RLock()
|
||||
f, ok := modelFactories[spec.Type]
|
||||
regMu.RUnlock()
|
||||
if !ok {
|
||||
return BuiltModel{}, fmt.Errorf("unknown model type %q", spec.Type)
|
||||
}
|
||||
return f(spec, deps)
|
||||
}
|
||||
|
||||
// RegisteredConstraints returns the names of every registered constraint type.
|
||||
func RegisteredConstraints() []string {
|
||||
regMu.RLock()
|
||||
defer regMu.RUnlock()
|
||||
out := make([]string, 0, len(constraintFactories))
|
||||
for k := range constraintFactories {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredModels returns the names of every registered model type.
|
||||
func RegisteredModels() []string {
|
||||
regMu.RLock()
|
||||
defer regMu.RUnlock()
|
||||
out := make([]string, 0, len(modelFactories))
|
||||
for k := range modelFactories {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Built-in registrations ------------------------------------------------
|
||||
|
||||
func init() {
|
||||
RegisterConstraint("altitude", buildAltitude)
|
||||
RegisterConstraint("time", buildTime)
|
||||
RegisterConstraint("terrain_contact", buildTerrainContact)
|
||||
RegisterConstraint("polygon", buildPolygon)
|
||||
|
||||
RegisterModel("constant_rate", buildConstantRate)
|
||||
RegisterModel("parachute_descent", buildParachuteDescent)
|
||||
RegisterModel("piecewise", buildPiecewise)
|
||||
RegisterModel("wind", buildWind)
|
||||
}
|
||||
|
||||
func buildAltitude(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
|
||||
op, err := ParseOperator(spec.Op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("altitude: %w", err)
|
||||
}
|
||||
act, err := ParseAction(spec.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("altitude: %w", err)
|
||||
}
|
||||
return Altitude{Op: op, Limit: spec.Limit, On: act}, nil
|
||||
}
|
||||
|
||||
func buildTime(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
|
||||
op, err := ParseOperator(spec.Op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("time: %w", err)
|
||||
}
|
||||
act, err := ParseAction(spec.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("time: %w", err)
|
||||
}
|
||||
return Time{Op: op, Limit: spec.Limit, On: act}, nil
|
||||
}
|
||||
|
||||
func buildTerrainContact(spec ConstraintSpec, deps BuildDeps) (Constraint, error) {
|
||||
if deps.Terrain == nil {
|
||||
return nil, fmt.Errorf("terrain_contact requires a terrain provider")
|
||||
}
|
||||
act, err := ParseAction(spec.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("terrain_contact: %w", err)
|
||||
}
|
||||
return TerrainContact{Provider: deps.Terrain, On: act}, nil
|
||||
}
|
||||
|
||||
func buildPolygon(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
|
||||
if len(spec.Vertices) < 3 {
|
||||
return nil, fmt.Errorf("polygon requires at least 3 vertices")
|
||||
}
|
||||
act, err := ParseAction(spec.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("polygon: %w", err)
|
||||
}
|
||||
mode := PolygonInside
|
||||
switch spec.Mode {
|
||||
case "", "inside":
|
||||
mode = PolygonInside
|
||||
case "outside":
|
||||
mode = PolygonOutside
|
||||
default:
|
||||
return nil, fmt.Errorf("polygon: unknown mode %q", spec.Mode)
|
||||
}
|
||||
return Polygon{Vertices: spec.Vertices, Mode: mode, On: act, Label: spec.Label}, nil
|
||||
}
|
||||
|
||||
func buildConstantRate(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
|
||||
return BuiltModel{Model: ConstantRate(spec.Rate)}, nil
|
||||
}
|
||||
|
||||
func buildParachuteDescent(spec ModelSpec, _ 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
|
||||
}
|
||||
|
||||
func buildWind(_ ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
if deps.Wind == nil {
|
||||
return BuiltModel{}, fmt.Errorf("wind model requires a loaded wind field")
|
||||
}
|
||||
return BuiltModel{Model: WindTransport(deps.Wind, deps.Events)}, nil
|
||||
}
|
||||
|
||||
func buildPiecewise(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
needsCtx := false
|
||||
for _, seg := range spec.Segments {
|
||||
if seg.Reference == "propagator_start" {
|
||||
needsCtx = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsCtx {
|
||||
// Eager build: resolve any "profile_start" relative segments using
|
||||
// the launch time we know at build time only when we have one.
|
||||
// Without context, treat profile_start the same as absolute (the
|
||||
// caller is expected to pre-resolve), and absolute as absolute.
|
||||
segs := make([]RateSegment, 0, len(spec.Segments))
|
||||
for _, s := range spec.Segments {
|
||||
if s.Reference == "profile_start" {
|
||||
return BuiltModel{}, fmt.Errorf("piecewise: profile_start reference requires a stage context — supply via lazy build")
|
||||
}
|
||||
segs = append(segs, RateSegment{Until: s.Until, Rate: s.Rate})
|
||||
}
|
||||
base := Piecewise(segs)
|
||||
return BuiltModel{Model: maybeAddWind(base, spec.IncludeWind, deps)}, nil
|
||||
}
|
||||
// Lazy build — captures spec into a closure.
|
||||
return BuiltModel{
|
||||
Build: func(ctx StageContext) Model {
|
||||
segs := resolveSegments(spec.Segments, ctx)
|
||||
base := Piecewise(segs)
|
||||
return maybeAddWind(base, spec.IncludeWind, deps)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveSegments converts spec segments to engine.RateSegment using the
|
||||
// stage context to resolve relative references.
|
||||
func resolveSegments(in []PiecewiseSegmentSpec, ctx StageContext) []RateSegment {
|
||||
out := make([]RateSegment, 0, len(in))
|
||||
for _, s := range in {
|
||||
var until float64
|
||||
switch s.Reference {
|
||||
case "", "absolute":
|
||||
until = s.Until
|
||||
case "profile_start":
|
||||
until = ctx.ProfileStart + s.Until
|
||||
case "propagator_start":
|
||||
until = ctx.PropagatorStart + s.Until
|
||||
}
|
||||
out = append(out, RateSegment{Until: until, Rate: s.Rate})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if deps.Wind == nil {
|
||||
return base
|
||||
}
|
||||
return Sum(base, WindTransport(deps.Wind, deps.Events))
|
||||
}
|
||||
|
|
@ -1,27 +1,27 @@
|
|||
// Package engine is the trajectory calculation engine. It composes
|
||||
// propagators (model-driven integrators) into profiles (ordered chains) and
|
||||
// runs them over a wind field.
|
||||
// propagators (model-driven integrators) into profiles (ordered chains)
|
||||
// over a wind field.
|
||||
//
|
||||
// The engine has no direct dependency on any specific data source: wind data
|
||||
// is consumed through weather.WindField and terrain data through any type
|
||||
// satisfying TerrainProvider.
|
||||
// The engine has no direct dependency on any specific data source: wind
|
||||
// data is consumed through weather.WindField and terrain data through
|
||||
// any type satisfying TerrainProvider.
|
||||
package engine
|
||||
|
||||
// State holds the spatial state of the balloon. When returned by a Model
|
||||
// the same struct is interpreted as the per-second time derivative of state.
|
||||
// the same struct is interpreted as the per-second time derivative.
|
||||
type State struct {
|
||||
// Lat is degrees latitude in [-90, 90] (or deg/s when returned as a derivative).
|
||||
Lat float64
|
||||
// Lng is degrees longitude in [0, 360) (or deg/s as a derivative).
|
||||
Lng float64
|
||||
// Altitude is metres above mean sea level (or m/s as a derivative).
|
||||
Altitude float64
|
||||
// Lat is degrees latitude in [-90, 90].
|
||||
Lat float64 `json:"lat"`
|
||||
// Lng is degrees longitude in [0, 360).
|
||||
Lng float64 `json:"lng"`
|
||||
// Altitude is metres above mean sea level.
|
||||
Altitude float64 `json:"altitude"`
|
||||
}
|
||||
|
||||
// Model returns the time derivative of state at (t, s).
|
||||
//
|
||||
// The derivative is direction-independent; the integrator applies the sign
|
||||
// of dt for reverse propagation.
|
||||
// The derivative is direction-independent; the integrator applies the
|
||||
// sign of dt for reverse propagation.
|
||||
type Model func(t float64, s State) State
|
||||
|
||||
// TrajectoryPoint is one sampled point of an integration result.
|
||||
|
|
@ -32,9 +32,7 @@ type TrajectoryPoint struct {
|
|||
Altitude float64
|
||||
}
|
||||
|
||||
// Direction is the time direction of integration. Forward (+1) integrates
|
||||
// from launch to landing; Reverse (-1) integrates from a known landing back
|
||||
// to a candidate launch point.
|
||||
// Direction is the time direction of integration.
|
||||
type Direction int8
|
||||
|
||||
const (
|
||||
|
|
@ -42,28 +40,39 @@ const (
|
|||
Reverse Direction = -1
|
||||
)
|
||||
|
||||
// Action describes what the profile runner should do when a Constraint
|
||||
// reports a violation.
|
||||
// Action is what the profile runner does on a constraint violation.
|
||||
type Action int
|
||||
|
||||
const (
|
||||
// ActionStop ends the current propagator at the (refined) violation point.
|
||||
// This matches the only behaviour available in the reference Tawhiri solver.
|
||||
// ActionStop ends the current propagator at the refined violation point.
|
||||
ActionStop Action = iota
|
||||
// ActionFallback ends the current propagator and starts its Fallback
|
||||
// propagator from the violation point. Useful for "if max altitude is
|
||||
// reached during ascent, switch to descent" profiles.
|
||||
// propagator from the refined violation point.
|
||||
ActionFallback
|
||||
// ActionClip clips the violated coordinate to the boundary and continues
|
||||
// integration. Useful for soft constraints such as "max altitude floor".
|
||||
// integration.
|
||||
ActionClip
|
||||
)
|
||||
|
||||
// Constraint reports when integration should stop, branch, or clip.
|
||||
//
|
||||
// A constraint is direction-agnostic: it reads state and decides. The profile
|
||||
// runner is responsible for refining the trigger point via binary search and
|
||||
// dispatching the configured Action.
|
||||
// ParseAction maps "stop" | "fallback" | "clip" to an Action.
|
||||
func ParseAction(s string) (Action, error) {
|
||||
switch s {
|
||||
case "", "stop":
|
||||
return ActionStop, nil
|
||||
case "fallback":
|
||||
return ActionFallback, nil
|
||||
case "clip":
|
||||
return ActionClip, nil
|
||||
default:
|
||||
return 0, errUnknownAction(s)
|
||||
}
|
||||
}
|
||||
|
||||
type errUnknownAction string
|
||||
|
||||
func (e errUnknownAction) Error() string { return "unknown constraint action " + string(e) }
|
||||
|
||||
// Constraint defines a stopping, branching, or clipping condition.
|
||||
type Constraint interface {
|
||||
// Name identifies the constraint in logs and result metadata.
|
||||
Name() string
|
||||
|
|
@ -74,7 +83,79 @@ type Constraint interface {
|
|||
}
|
||||
|
||||
// TerrainProvider returns ground elevation in metres at a coordinate.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type TerrainProvider interface {
|
||||
Elevation(lat, lng float64) float64
|
||||
}
|
||||
|
||||
// StageContext is provided to Propagator.BuildModel and BuildConstraints by
|
||||
// the profile runner immediately before each stage executes.
|
||||
type StageContext struct {
|
||||
// ProfileStart is the UNIX timestamp of the profile's initial launch.
|
||||
ProfileStart float64
|
||||
// PropagatorStart is the UNIX timestamp at which this propagator begins
|
||||
// running — equal to ProfileStart for the first stage; the end-time of
|
||||
// the previous stage thereafter.
|
||||
PropagatorStart float64
|
||||
// Launch is the profile's initial state.
|
||||
Launch State
|
||||
// PropagatorState is the state at which this propagator begins.
|
||||
PropagatorState State
|
||||
// Direction is the integration direction the profile is configured with.
|
||||
Direction Direction
|
||||
}
|
||||
|
||||
// Outcome describes how a propagator's run ended.
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
// OutcomeStopped means a Constraint with ActionStop fired.
|
||||
OutcomeStopped Outcome = iota
|
||||
// OutcomeFallback means a Constraint with ActionFallback fired.
|
||||
OutcomeFallback
|
||||
// OutcomeContinued means the propagator finished without a constraint
|
||||
// firing — only seen when a propagator is misconfigured to run unbounded.
|
||||
OutcomeContinued
|
||||
)
|
||||
|
||||
// String renders the outcome as a stable string for API serialisation.
|
||||
func (o Outcome) String() string {
|
||||
switch o {
|
||||
case OutcomeStopped:
|
||||
return "stopped"
|
||||
case OutcomeFallback:
|
||||
return "fallback"
|
||||
default:
|
||||
return "continued"
|
||||
}
|
||||
}
|
||||
|
||||
// Result is the output of running one propagator.
|
||||
type Result struct {
|
||||
// Propagator is the propagator's Name.
|
||||
Propagator string
|
||||
|
||||
// Points is the emitted trajectory.
|
||||
Points []TrajectoryPoint
|
||||
|
||||
// Outcome describes how the propagator terminated.
|
||||
Outcome Outcome
|
||||
|
||||
// Constraint is the constraint that fired, or nil if Outcome is OutcomeContinued.
|
||||
Constraint Constraint
|
||||
// ConstraintName captures Constraint.Name() at fire time so callers can
|
||||
// serialise the result after the Constraint has been garbage collected.
|
||||
ConstraintName string
|
||||
|
||||
// ViolationTime / ViolationState describe the first integration step at
|
||||
// which the constraint reported a violation, before binary-search refinement.
|
||||
ViolationTime float64
|
||||
ViolationState State
|
||||
|
||||
// RefinedTime / RefinedState describe the refined violation point that
|
||||
// appears as the propagator's last trajectory point.
|
||||
RefinedTime float64
|
||||
RefinedState State
|
||||
|
||||
// Events is the aggregated set of non-fatal observations from this stage.
|
||||
Events []EventSummary
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue