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 // into a single propagator. Equivalent to Tawhiri's LinearModel. func Sum(models ...Model) Model { if len(models) == 1 { return models[0] } return func(t float64, s State) State { var sum State for _, m := range models { d := m(t, s) sum.Lat += d.Lat sum.Lng += d.Lng sum.Altitude += d.Altitude } return sum } } // ConstantRate returns a model with a constant vertical velocity (m/s). // A positive rate is upward (ascent); a negative rate is downward. func ConstantRate(rate float64) Model { 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. // // seaLevelRate is the descent speed at sea level (m/s, positive number). // The terminal velocity at altitude is computed as // // v = -k / sqrt(rho(alt)), k = seaLevelRate * 1.1045, // // using the NASA atmosphere model for rho. Equivalent to Tawhiri's drag_descent. func ParachuteDescent(seaLevelRate float64) Model { k := seaLevelRate * 1.1045 return func(_ float64, s State) State { return State{Altitude: -k / math.Sqrt(nasaDensity(s.Altitude))} } } // 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. 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. 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 } // 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. func Piecewise(segments []RateSegment) Model { if len(segments) == 0 { return ConstantRate(0) } sorted := append([]RateSegment(nil), segments...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Until < sorted[j].Until }) finalRate := sorted[len(sorted)-1].Rate return func(t float64, _ State) State { idx := sort.Search(len(sorted), func(i int) bool { return sorted[i].Until > t }) if idx == len(sorted) { return State{Altitude: finalRate} } return State{Altitude: sorted[idx].Rate} } } // 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. // // 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 { const earthR = 6371009.0 const piOver180 = math.Pi / 180.0 const degPerRad = 180.0 / math.Pi return func(t float64, s State) State { sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude) if err != nil { return State{} } if sample.AboveModel && warnings != nil { warnings.AltitudeTooHigh.Add(1) } r := earthR + s.Altitude return State{ Lat: degPerRad * sample.V / r, Lng: degPerRad * sample.U / (r * math.Cos(s.Lat*piOver180)), } } }