feat: polish & windviz & deploy

This commit is contained in:
Anatoly Antonov 2026-05-30 06:29:39 +09:00
parent 81b8e763bd
commit 465ad00f7b
78 changed files with 20622 additions and 2154 deletions

View file

@ -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
}

View file

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

View file

@ -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.

View file

@ -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,
}
}

View file

@ -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

View file

@ -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
}

View file

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

View file

@ -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