fix(input): correct lon mapping

This commit is contained in:
gili8420 2026-08-04 13:40:50 +09:00
parent 19557f3a62
commit 84d1664b29
20 changed files with 1197 additions and 137 deletions

View file

@ -1,7 +1,9 @@
package engine
import (
"fmt"
"math"
"strings"
"testing"
"time"
@ -261,3 +263,157 @@ func TestPolygonOutsideAntimeridian(t *testing.T) {
t.Errorf("(0, 0) should be outside")
}
}
// horizonWind has data up to horizon seconds and fails past it, the way a real
// dataset's time axis does (numerics.AxisError from Axis.Locate).
type horizonWind struct{ horizon float64 }
func (w horizonWind) Wind(t, _, _, _ float64) (weather.Sample, error) {
if t > w.horizon {
return weather.Sample{}, fmt.Errorf("hour=%v out of range", t/3600)
}
return weather.Sample{U: 10, V: 0}, nil
}
func (w horizonWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
func (w horizonWind) Source() string { return "test" }
// TestWindSamplingFailureIsRecordedNotSwallowed pins the difference between an
// event and a failure.
//
// WindTransport used to discard the sampler's error and return a zero rate. A
// zero rate is indistinguishable from calm air, so a launch past the dataset's
// horizon integrated with no wind at all and produced a balloon that took off
// and landed on the same spot — returned as a successful forecast. An error from
// the sampler means there was no data, so the trajectory past that point is not
// a forecast and must not be presented as one.
func TestWindSamplingFailureIsRecordedNotSwallowed(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(horizonWind{horizon: 600}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
err := sink.Err()
if err == nil {
t.Fatal("sink reports no failure; the sampling error was swallowed")
}
if !strings.Contains(err.Error(), "out of range") {
t.Errorf("error %q does not carry the sampler's reason", err)
}
}
// TestWindSamplingSuccessLeavesNoFailure keeps the check above from firing on
// healthy runs, which would turn every prediction into a 400.
func TestWindSamplingSuccessLeavesNoFailure(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(horizonWind{horizon: 1e9}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
if err := sink.Err(); err != nil {
t.Fatalf("healthy run reported a failure: %v", err)
}
}
// TestAboveModelStaysNonFatal separates the two paths explicitly: extrapolating
// above the highest pressure level is a warning the caller may ignore, and must
// not become a hard failure.
func TestAboveModelStaysNonFatal(t *testing.T) {
sink := NewEventSink()
ascend := &Propagator{
Name: "ascent",
Step: 60,
Model: Sum(ConstantRate(5), WindTransport(aboveModelWind{}, sink)),
Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 30000, On: ActionStop}},
}
prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward}
prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, sink)
if err := sink.Err(); err != nil {
t.Fatalf("above_model became a failure: %v", err)
}
if len(sink.Snapshot()) == 0 {
t.Error("above_model event was not emitted")
}
}
// TestEveryModelHonoursIncludeWind pins the promise ModelSpec.IncludeWind makes.
//
// buildConstantRate and buildParachuteDescent both took BuildDeps as `_` and
// returned a bare vertical model, so include_wind was accepted and discarded.
// Only buildPiecewise ever called maybeAddWind. POST /api/v2/prediction with the
// spec's own documented example therefore answered 200 with a trajectory that
// rose and fell on the spot — a wind-free forecast presented as a forecast.
//
// The loop over modelFactories is the part that matters going forward: a newly
// registered model type cannot be added without deciding here what include_wind
// does to it, which is exactly the step that was skipped before.
func TestEveryModelHonoursIncludeWind(t *testing.T) {
cases := map[string]struct {
spec ModelSpec
wantError bool
}{
"constant_rate": {spec: ModelSpec{Type: "constant_rate", Rate: 5, IncludeWind: true}},
"parachute_descent": {spec: ModelSpec{Type: "parachute_descent", SeaLevelRate: 5, IncludeWind: true}},
"piecewise": {spec: ModelSpec{Type: "piecewise", IncludeWind: true,
Segments: []PiecewiseSegmentSpec{{Until: math.Inf(1), Rate: 5}}}},
// include_wind on the wind model itself would sum the wind into itself.
// Refused rather than ignored: a silently doubled wind is a wrong forecast
// that looks right.
"wind": {spec: ModelSpec{Type: "wind", IncludeWind: true}, wantError: true},
}
for name := range modelFactories {
if _, ok := cases[name]; !ok {
t.Errorf("model %q is registered but has no include_wind case here", name)
}
}
deps := BuildDeps{Wind: fixedWind{u: 10, v: -4}}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
built, err := BuildModel(tc.spec, deps)
if tc.wantError {
if err == nil {
t.Fatal("expected include_wind to be refused, got no error")
}
return
}
if err != nil {
t.Fatalf("BuildModel: %v", err)
}
m := built.Model
if built.Build != nil {
m = built.Build(StageContext{})
}
rate := m(0, State{Lat: 0, Lng: 0, Altitude: 1000})
if rate.East != 10 || rate.North != -4 {
t.Errorf("include_wind ignored: east/north = %v/%v, want 10/-4", rate.East, rate.North)
}
})
}
}
// TestIncludeWindWithoutAFieldIsRefused covers the other half of maybeAddWind,
// which returned the wind-free model when deps.Wind was nil. A request that asks
// for wind and cannot get it must fail, not quietly become a vertical drop.
func TestIncludeWindWithoutAFieldIsRefused(t *testing.T) {
for _, typ := range []string{"constant_rate", "parachute_descent", "piecewise"} {
t.Run(typ, func(t *testing.T) {
spec := ModelSpec{Type: typ, Rate: 5, SeaLevelRate: 5, IncludeWind: true,
Segments: []PiecewiseSegmentSpec{{Until: math.Inf(1), Rate: 5}}}
if _, err := BuildModel(spec, BuildDeps{}); err == nil {
t.Error("include_wind with no wind field was accepted")
}
})
}
}

View file

@ -26,10 +26,13 @@ type EventSummary struct {
}
// EventSink collects events from models and the integrator, aggregating
// duplicate types into a single EventSummary. Safe for concurrent use.
// duplicate types into a single EventSummary. It also carries the run's first
// unrecoverable failure, which is a different thing from an event — see Fail.
// Safe for concurrent use.
type EventSink struct {
mu sync.Mutex
summaries map[string]*EventSummary
err error
}
// NewEventSink returns an empty sink.
@ -61,6 +64,42 @@ func (s *EventSink) Emit(typ string, t float64, state State, message string) {
}
}
// Fail records the run's first unrecoverable error. Nil-safe, like Emit.
//
// Deliberately not an event. An event is an observation the caller may choose to
// ignore: "above_model" means samples above the highest pressure level were
// extrapolated, which degrades the answer without invalidating it. A failure
// means the sampler had no data at all, so the trajectory from that point on is
// not a forecast and must not be returned as one.
//
// Routing both through Emit is how the second came to be treated like the first.
// WindTransport discarded the sampler's error and returned a zero rate, which is
// indistinguishable from calm air, so any launch past the dataset's horizon
// integrated with no wind and produced a balloon that took off and landed on the
// same spot — reported as a successful prediction.
//
// Only the first failure is kept: it is the one that explains the run.
func (s *EventSink) Fail(err error) {
if s == nil || err == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err == nil {
s.err = err
}
}
// Err returns the first failure passed to Fail, or nil if the run was sound.
func (s *EventSink) Err() error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
// Snapshot returns a stable copy of every summary in deterministic order
// (sorted by Type).
func (s *EventSink) Snapshot() []EventSummary {

View file

@ -1,7 +1,9 @@
package engine
import (
"fmt"
"sort"
"time"
"predictor-refactored/internal/numerics"
"predictor-refactored/internal/weather"
@ -84,6 +86,11 @@ func WindTransport(field weather.WindField, events *EventSink) Model {
return func(t float64, s State) numerics.Rate {
sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude)
if err != nil {
// Recorded, not swallowed. A zero rate here is indistinguishable
// from calm air, so returning one turns "we have no data for this
// time" into "the balloon did not move" — see EventSink.Fail.
events.Fail(fmt.Errorf("no wind data at %s: %w",
time.Unix(int64(t), 0).UTC().Format(time.RFC3339), err))
return numerics.Rate{}
}
if sample.AboveModel && events != nil {

View file

@ -205,21 +205,32 @@ func buildPolygon(spec ConstraintSpec, _ BuildDeps) (Constraint, error) {
return NewPolygon(spec.Vertices, mode, act, spec.Label), nil
}
func buildConstantRate(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
return BuiltModel{Model: ConstantRate(spec.Rate)}, nil
func buildConstantRate(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
if err := checkWind(spec.IncludeWind, deps); err != nil {
return BuiltModel{}, err
}
return BuiltModel{Model: addWind(ConstantRate(spec.Rate), spec.IncludeWind, deps)}, nil
}
func buildParachuteDescent(spec ModelSpec, _ BuildDeps) (BuiltModel, error) {
func buildParachuteDescent(spec ModelSpec, deps 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
if err := checkWind(spec.IncludeWind, deps); err != nil {
return BuiltModel{}, err
}
return BuiltModel{Model: addWind(ParachuteDescent(spec.SeaLevelRate), spec.IncludeWind, deps)}, nil
}
func buildWind(_ ModelSpec, deps BuildDeps) (BuiltModel, error) {
func buildWind(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
if deps.Wind == nil {
return BuiltModel{}, fmt.Errorf("wind model requires a loaded wind field")
}
// Refused rather than ignored: summing the wind into itself would double the
// drift, and a doubled wind is a wrong forecast that looks like a right one.
if spec.IncludeWind {
return BuiltModel{}, fmt.Errorf("wind model does not take include_wind: it would sum the wind into itself")
}
return BuiltModel{Model: WindTransport(deps.Wind, deps.Events)}, nil
}
@ -231,12 +242,17 @@ func buildPiecewise(spec ModelSpec, deps BuildDeps) (BuiltModel, error) {
return BuiltModel{}, fmt.Errorf("piecewise: unknown segment reference %q", s.Reference)
}
}
// Checked here, not in the closure below: the profile runner calls Build once
// per stage and has nowhere to return an error to.
if err := checkWind(spec.IncludeWind, deps); err != nil {
return BuiltModel{}, err
}
// 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 {
return maybeAddWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps)
return addWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps)
},
}, nil
}
@ -266,12 +282,26 @@ func segmentBase(reference string, ctx StageContext) float64 {
}
}
// 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
// checkWind reports whether include_wind can be satisfied at all.
//
// A request that asks for wind and cannot have it is an error. This used to
// return the wind-free model instead, which turned a missing wind field into a
// balloon that rose and fell on the spot, reported as a successful forecast.
func checkWind(includeWind bool, deps BuildDeps) error {
if includeWind && deps.Wind == nil {
return fmt.Errorf("include_wind requires a loaded wind field")
}
if deps.Wind == nil {
return nil
}
// addWind sums a WindTransport model into base when the spec asks for it.
//
// Callers must have called checkWind first. The split exists because the
// piecewise builder combines inside a closure the profile runner invokes per
// stage, with no way to surface an error — so validation happens at build time
// and combination stays infallible, rather than an error being swallowed there.
func addWind(base Model, includeWind bool, deps BuildDeps) Model {
if !includeWind {
return base
}
return Sum(base, WindTransport(deps.Wind, deps.Events))