feat: polish & windviz & deploy
This commit is contained in:
parent
81b8e763bd
commit
465ad00f7b
78 changed files with 20622 additions and 2154 deletions
|
|
@ -2,7 +2,8 @@ package engine
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"predictor-refactored/internal/numerics"
|
||||
)
|
||||
|
||||
// Altitude triggers when the balloon altitude satisfies Op against Limit.
|
||||
|
|
@ -31,9 +32,9 @@ type Time struct {
|
|||
On Action
|
||||
}
|
||||
|
||||
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 }
|
||||
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 }
|
||||
|
||||
// TerrainContact triggers when the ground elevation exceeds the balloon's
|
||||
// altitude — i.e. the balloon has hit the ground.
|
||||
|
|
@ -69,23 +70,30 @@ type PolygonVertex struct {
|
|||
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.
|
||||
// Polygon is a constraint over a closed geographic polygon, evaluated in
|
||||
// plate-carrée coordinates with antimeridian handling (see
|
||||
// numerics.PointInPolygon). Build one with NewPolygon so the flattened
|
||||
// vertex slices used by the hot path are precomputed.
|
||||
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
|
||||
|
||||
// Precomputed parallel vertex slices for numerics.PointInPolygon.
|
||||
polyLat, polyLng []float64
|
||||
}
|
||||
|
||||
// NewPolygon builds a Polygon, precomputing the flattened vertex slices.
|
||||
func NewPolygon(verts []PolygonVertex, mode PolygonMode, on Action, label string) Polygon {
|
||||
lat := make([]float64, len(verts))
|
||||
lng := make([]float64, len(verts))
|
||||
for i, v := range verts {
|
||||
lat[i], lng[i] = v.Lat, v.Lng
|
||||
}
|
||||
return Polygon{Vertices: verts, Mode: mode, On: on, Label: label, polyLat: lat, polyLng: lng}
|
||||
}
|
||||
|
||||
func (c Polygon) Name() string {
|
||||
|
|
@ -101,49 +109,9 @@ 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)
|
||||
in := numerics.PointInPolygon(s.Lat, s.Lng, c.polyLat, c.polyLng)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ func TestConstantAscentToBurst(t *testing.T) {
|
|||
t.Errorf("RefinedState not populated")
|
||||
}
|
||||
|
||||
last := results[0].Points[len(results[0].Points)-1]
|
||||
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(last.Time-wantTime) > 1 {
|
||||
t.Errorf("burst time = %v, want within 1s of %v", last.Time, wantTime)
|
||||
if math.Abs(lastT-wantTime) > 1 {
|
||||
t.Errorf("burst time = %v, want within 1s of %v", lastT, wantTime)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ func TestProfileWithFallback(t *testing.T) {
|
|||
t.Errorf("second outcome = %v, want OutcomeStopped", results[1].Outcome)
|
||||
}
|
||||
|
||||
last := results[1].Points[len(results[1].Points)-1]
|
||||
_, last := results[1].Path.Last()
|
||||
if math.Abs(last.Altitude) > 5 {
|
||||
t.Errorf("final altitude = %v, want within 5m of 0", last.Altitude)
|
||||
}
|
||||
|
|
@ -103,12 +103,12 @@ func TestReverseDirection(t *testing.T) {
|
|||
prof := Profile{Stages: []*Propagator{desc}, Direction: Reverse}
|
||||
results := prof.Run(0, State{Altitude: 100}, NewEventSink())
|
||||
|
||||
last := results[0].Points[len(results[0].Points)-1]
|
||||
lastT, last := results[0].Path.Last()
|
||||
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)
|
||||
if lastT >= 0 {
|
||||
t.Errorf("reverse final time = %v, want < 0", lastT)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,15 +206,25 @@ func TestWindTransportEmitsAboveModel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStateAddWrapsLongitude(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +236,7 @@ func TestPolygonInside(t *testing.T) {
|
|||
{Lat: 1, Lng: 1},
|
||||
{Lat: 1, Lng: -1},
|
||||
}
|
||||
c := Polygon{Vertices: square, Mode: PolygonInside, On: ActionStop}
|
||||
c := NewPolygon(square, PolygonInside, ActionStop, "")
|
||||
if !c.Violated(0, State{Lat: 0, Lng: 0}) {
|
||||
t.Errorf("origin should be inside the square")
|
||||
}
|
||||
|
|
@ -244,7 +254,7 @@ func TestPolygonOutsideAntimeridian(t *testing.T) {
|
|||
{Lat: 10, Lng: 190},
|
||||
{Lat: 10, Lng: 170},
|
||||
}
|
||||
c := Polygon{Vertices: poly, Mode: PolygonInside, On: ActionStop}
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"math"
|
||||
"sort"
|
||||
|
||||
"predictor-refactored/internal/numerics"
|
||||
"predictor-refactored/internal/weather"
|
||||
)
|
||||
|
||||
|
|
@ -45,29 +46,10 @@ func ConstantRate(rate float64) Model {
|
|||
func ParachuteDescent(seaLevelRate float64) Model {
|
||||
k := seaLevelRate * 1.1045
|
||||
return func(_ float64, s State) State {
|
||||
return State{Altitude: -k / math.Sqrt(nasaDensity(s.Altitude))}
|
||||
return State{Altitude: -k / math.Sqrt(numerics.NasaDensity(s.Altitude))}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case alt > 25000:
|
||||
temp = -131.21 + 0.00299*alt
|
||||
pressure = 2.488 * math.Pow((temp+273.1)/216.6, -11.388)
|
||||
case alt > 11000:
|
||||
temp = -56.46
|
||||
pressure = 22.65 * math.Exp(1.73-0.000157*alt)
|
||||
default:
|
||||
temp = 15.04 - 0.00649*alt
|
||||
pressure = 101.29 * math.Pow((temp+273.1)/288.08, 5.256)
|
||||
}
|
||||
return pressure / (0.2869 * (temp + 273.1))
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -30,39 +30,30 @@ func (p *Profile) Run(t0 float64, launch State, events *EventSink) []Result {
|
|||
results := make([]Result, 0, len(p.Stages))
|
||||
t, s := t0, launch
|
||||
|
||||
for i := 0; i < len(p.Stages); i++ {
|
||||
stage := p.Stages[i]
|
||||
ctx := StageContext{
|
||||
ProfileStart: t0,
|
||||
PropagatorStart: t,
|
||||
Launch: launch,
|
||||
PropagatorState: s,
|
||||
Direction: p.Direction,
|
||||
}
|
||||
res := stage.run(ctx, t, s, p.Globals, events)
|
||||
for _, stage := range p.Stages {
|
||||
res := stage.run(p.context(t0, t, launch, s), 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}
|
||||
t, s = res.Path.Last()
|
||||
|
||||
// Follow Fallback chains until none remains.
|
||||
for res.Outcome == OutcomeFallback && stage.Fallback != nil {
|
||||
stage = stage.Fallback
|
||||
ctx = StageContext{
|
||||
ProfileStart: t0,
|
||||
PropagatorStart: t,
|
||||
Launch: launch,
|
||||
PropagatorState: s,
|
||||
Direction: p.Direction,
|
||||
}
|
||||
res = stage.run(ctx, t, s, p.Globals, events)
|
||||
res = stage.run(p.context(t0, t, launch, s), 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}
|
||||
t, s = res.Path.Last()
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// context builds the StageContext for a stage starting at (tStart, sStart).
|
||||
func (p *Profile) context(t0, tStart float64, launch, sStart State) StageContext {
|
||||
return StageContext{
|
||||
ProfileStart: t0,
|
||||
PropagatorStart: tStart,
|
||||
Launch: launch,
|
||||
PropagatorState: sStart,
|
||||
Direction: p.Direction,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"predictor-refactored/internal/numerics"
|
||||
)
|
||||
import "predictor-refactored/internal/numerics"
|
||||
|
||||
// Propagator advances state under one Model, checking a set of Constraints
|
||||
// after every integration step.
|
||||
|
|
@ -11,9 +9,12 @@ import (
|
|||
// 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.
|
||||
//
|
||||
// The per-step numerics (RK4 stepping, crossing refinement) are delegated to
|
||||
// the numerics package; this type owns only the orchestration: constraint
|
||||
// evaluation, action dispatch, and trajectory assembly.
|
||||
type Propagator struct {
|
||||
// Name identifies the propagator in trajectory metadata. Optional —
|
||||
// callers using sequential profile chains may leave it empty.
|
||||
// Name identifies the propagator in trajectory metadata. Optional.
|
||||
Name string
|
||||
|
||||
// Step is the magnitude of the integration step in seconds (always positive).
|
||||
|
|
@ -39,6 +40,18 @@ type Propagator struct {
|
|||
Tolerance float64
|
||||
}
|
||||
|
||||
// estimatedSteps is the initial Path capacity; a typical balloon stage is a
|
||||
// few hundred 60-second steps.
|
||||
const estimatedSteps = 256
|
||||
|
||||
// DefaultMaxSteps bounds the number of integration steps a single propagator
|
||||
// may take. It is a safety backstop, not a physical limit: a profile whose
|
||||
// constraints never fire (e.g. a stage with no effective terminator) would
|
||||
// otherwise integrate forever and exhaust memory. At the default 60-second
|
||||
// step this allows ~8 simulated years, far beyond any real flight, so it only
|
||||
// ever trips on a misconfigured profile.
|
||||
const DefaultMaxSteps = 1_000_000
|
||||
|
||||
// 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. events receives non-fatal observations.
|
||||
|
|
@ -58,70 +71,53 @@ func (p *Propagator) run(ctx StageContext, t0 float64, s0 State, globals []Const
|
|||
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)
|
||||
field := numerics.Field(model)
|
||||
|
||||
out := Result{
|
||||
Propagator: p.Name,
|
||||
Outcome: OutcomeContinued,
|
||||
Points: []TrajectoryPoint{{
|
||||
Time: t0, Lat: s0.Lat, Lng: s0.Lng, Altitude: s0.Altitude,
|
||||
}},
|
||||
}
|
||||
out := Result{Propagator: p.Name, Outcome: OutcomeContinued, Path: numerics.NewPath(estimatedSteps)}
|
||||
out.Path.Append(t0, s0)
|
||||
|
||||
t := t0
|
||||
s := s0
|
||||
|
||||
for {
|
||||
s2 := numerics.RK4Step(t, s, dt, deriv, add)
|
||||
t, s := t0, s0
|
||||
for range DefaultMaxSteps {
|
||||
s2 := numerics.RK4Step(t, s, dt, field)
|
||||
t2 := t + dt
|
||||
|
||||
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,
|
||||
})
|
||||
out.Path.Append(t, s)
|
||||
continue
|
||||
}
|
||||
|
||||
// Record the unrefined violation.
|
||||
out.ViolationTime = t2
|
||||
out.ViolationState = s2
|
||||
out.ViolationTime, out.ViolationState = t2, s2
|
||||
t3, s3 := numerics.RefineCrossing(t, s, t2, s2, c.Violated, tol)
|
||||
out.Constraint, out.ConstraintName = c, c.Name()
|
||||
|
||||
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:
|
||||
if c.Action() == 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,
|
||||
})
|
||||
out.RefinedTime, out.RefinedState = t3, s3
|
||||
out.Path.Append(t3, s3)
|
||||
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
|
||||
}
|
||||
|
||||
out.RefinedTime, out.RefinedState = t3, s3
|
||||
out.Path.Append(t3, s3)
|
||||
if c.Action() == ActionFallback {
|
||||
out.Outcome = OutcomeFallback
|
||||
} else {
|
||||
out.Outcome = OutcomeStopped
|
||||
}
|
||||
out.Events = events.Snapshot()
|
||||
return out
|
||||
}
|
||||
|
||||
// Step cap reached without any constraint firing — the profile has no
|
||||
// effective terminator for this stage. Stop safely rather than loop forever.
|
||||
events.Emit("max_steps", t, s,
|
||||
"integration step limit reached without a constraint firing; check the stage's terminator")
|
||||
out.Outcome = OutcomeContinued
|
||||
out.Events = events.Snapshot()
|
||||
return out
|
||||
}
|
||||
|
||||
// firstFiring scans local then global constraints for the first one whose
|
||||
|
|
@ -140,9 +136,9 @@ 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). Defined only for constraints with a
|
||||
// well-defined coordinate boundary; others fall through unchanged.
|
||||
// clipToConstraint adjusts s so the given constraint is exactly satisfied.
|
||||
// Defined only for constraints with a well-defined coordinate boundary;
|
||||
// others fall through unchanged.
|
||||
func clipToConstraint(c Constraint, s State) State {
|
||||
if alt, ok := c.(Altitude); ok {
|
||||
s.Altitude = alt.Limit
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ type BuiltModel struct {
|
|||
}
|
||||
|
||||
var (
|
||||
regMu sync.RWMutex
|
||||
regMu sync.RWMutex
|
||||
constraintFactories = map[string]ConstraintFactory{}
|
||||
modelFactories = map[string]ModelFactory{}
|
||||
)
|
||||
|
|
@ -202,7 +202,7 @@ func buildPolygon(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
|
|||
default:
|
||||
return nil, fmt.Errorf("polygon: unknown mode %q", spec.Mode)
|
||||
}
|
||||
return Polygon{Vertices: spec.Vertices, Mode: mode, On: act, Label: spec.Label}, nil
|
||||
return NewPolygon(spec.Vertices, mode, act, spec.Label), nil
|
||||
}
|
||||
|
||||
func buildConstantRate(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
|
||||
|
|
@ -224,34 +224,19 @@ func buildWind(_ ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
|||
}
|
||||
|
||||
func buildPiecewise(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
|
||||
needsCtx := false
|
||||
for _, seg := range spec.Segments {
|
||||
if seg.Reference == "propagator_start" {
|
||||
needsCtx = true
|
||||
break
|
||||
for _, s := range spec.Segments {
|
||||
switch s.Reference {
|
||||
case "", "absolute", "profile_start", "propagator_start":
|
||||
default:
|
||||
return BuiltModel{}, fmt.Errorf("piecewise: unknown segment reference %q", s.Reference)
|
||||
}
|
||||
}
|
||||
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.
|
||||
// Always build lazily: the profile runner supplies a StageContext before
|
||||
// each stage, which is what resolves absolute / profile-relative /
|
||||
// propagator-relative segment times uniformly.
|
||||
return BuiltModel{
|
||||
Build: func(ctx StageContext) Model {
|
||||
segs := resolveSegments(spec.Segments, ctx)
|
||||
base := Piecewise(segs)
|
||||
return maybeAddWind(base, spec.IncludeWind, deps)
|
||||
return maybeAddWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
package engine
|
||||
|
||||
import "math"
|
||||
|
||||
// pymod returns a % b with Python semantics: the result has the sign of b,
|
||||
// so for b > 0 the result is always in [0, b).
|
||||
func pymod(a, b float64) float64 {
|
||||
r := math.Mod(a, b)
|
||||
if r < 0 {
|
||||
r += b
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stateAdd is the RK4 integrator's update operation y + k*dy, with longitude
|
||||
// kept wrapped to [0, 360).
|
||||
//
|
||||
// Time is not stored in State — it is tracked separately by the integrator
|
||||
// and passed to Model.
|
||||
func stateAdd(y State, k float64, dy State) State {
|
||||
return State{
|
||||
Lat: y.Lat + k*dy.Lat,
|
||||
Lng: pymod(y.Lng+k*dy.Lng, 360),
|
||||
Altitude: y.Altitude + k*dy.Altitude,
|
||||
}
|
||||
}
|
||||
|
||||
// stateLerp computes the linear interpolation of two states by parameter l
|
||||
// in [0, 1]. Longitude uses lngLerp so that wrap-around is handled.
|
||||
func stateLerp(a, b State, l float64) State {
|
||||
return State{
|
||||
Lat: (1-l)*a.Lat + l*b.Lat,
|
||||
Lng: lngLerp(a.Lng, b.Lng, l),
|
||||
Altitude: (1-l)*a.Altitude + l*b.Altitude,
|
||||
}
|
||||
}
|
||||
|
||||
// lngLerp interpolates between two longitudes in [0, 360), choosing the
|
||||
// shorter great-circle arc.
|
||||
func lngLerp(a, b, l float64) float64 {
|
||||
l2 := 1 - l
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
l, l2 = l2, l
|
||||
}
|
||||
if b-a < 180 {
|
||||
return l2*a + l*b
|
||||
}
|
||||
return pymod(l2*(a+360)+l*b, 360)
|
||||
}
|
||||
|
|
@ -2,21 +2,23 @@
|
|||
// propagators (model-driven integrators) into profiles (ordered chains)
|
||||
// over a wind field.
|
||||
//
|
||||
// The engine orchestrates the calculation; the numerically heavy work
|
||||
// (RK4 stepping, crossing refinement, interpolation, atmosphere density,
|
||||
// vector and polygon math) lives in the numerics package so it can be
|
||||
// reimplemented in a faster language without touching this layer.
|
||||
//
|
||||
// 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.
|
||||
type State struct {
|
||||
// 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"`
|
||||
}
|
||||
import "predictor-refactored/internal/numerics"
|
||||
|
||||
// State is the spatial state of the balloon: latitude/longitude in degrees,
|
||||
// altitude in metres. When returned by a Model the same struct is the
|
||||
// per-second derivative. It is an alias of numerics.GeoVec so the engine and
|
||||
// the numeric core share one hot-path value type without conversions.
|
||||
type State = numerics.GeoVec
|
||||
|
||||
// Model returns the time derivative of state at (t, s).
|
||||
//
|
||||
|
|
@ -24,14 +26,6 @@ type State struct {
|
|||
// sign of dt for reverse propagation.
|
||||
type Model func(t float64, s State) State
|
||||
|
||||
// TrajectoryPoint is one sampled point of an integration result.
|
||||
type TrajectoryPoint struct {
|
||||
Time float64 // UNIX seconds
|
||||
Lat float64
|
||||
Lng float64
|
||||
Altitude float64
|
||||
}
|
||||
|
||||
// Direction is the time direction of integration.
|
||||
type Direction int8
|
||||
|
||||
|
|
@ -134,8 +128,8 @@ type Result struct {
|
|||
// Propagator is the propagator's Name.
|
||||
Propagator string
|
||||
|
||||
// Points is the emitted trajectory.
|
||||
Points []TrajectoryPoint
|
||||
// Path is the emitted trajectory in struct-of-arrays form.
|
||||
Path numerics.Path
|
||||
|
||||
// Outcome describes how the propagator terminated.
|
||||
Outcome Outcome
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue