diff --git a/cmd/vice/simconfig.go b/cmd/vice/simconfig.go index 7f26b5a6b..d5bb3511b 100644 --- a/cmd/vice/simconfig.go +++ b/cmd/vice/simconfig.go @@ -52,7 +52,8 @@ type NewSimConfiguration struct { selectedTCPs map[sim.TCP]bool // New UI state for improved flow - filterText string // search/filter for scenario selection + filterText string // search/filter for scenario selection + scheduleStartTimeText string // Weather filter UI state weatherFilter wx.WeatherFilter @@ -138,6 +139,8 @@ func (c *NewSimConfiguration) SetScenario(groupName, scenarioName string) { c.GroupName = groupName c.ScenarioSpec = spec c.ScenarioName = scenarioName + normalizeScheduleLaunchConfig(c.ScenarioSpec) + c.scheduleStartTimeText = formatScheduleStartTime(spec.LaunchConfig.ScheduleStartMinute) c.savedVFRDepartureRateScale = spec.LaunchConfig.VFRDepartureRateScale c.initDefaultWindDirection() c.fetchSeq++ @@ -150,6 +153,256 @@ func (c *NewSimConfiguration) SetScenario(groupName, scenarioName string) { go c.fetchMETAR(seq, facility, airports, spec) } +func normalizeScheduleLaunchConfig(spec *server.ScenarioSpec) { + lc := &spec.LaunchConfig + lc.ScheduleStartMinute = min(max(lc.ScheduleStartMinute, 0), 24*60-1) + lc.ScheduleArrivalPercentage = min(max(lc.ScheduleArrivalPercentage, 0), 100) + lc.ScheduleDeparturePercentage = min(max(lc.ScheduleDeparturePercentage, 0), 100) + + if len(spec.RealWorldSchedules) == 0 { + lc.TrafficSource = sim.TrafficSourceRandom + lc.ScheduleID = "" + return + } + + for _, schedule := range spec.RealWorldSchedules { + if schedule.ID == lc.ScheduleID { + return + } + } + lc.ScheduleID = spec.RealWorldSchedules[0].ID +} + +func selectedScheduleSummary(spec *server.ScenarioSpec) (sim.BuiltInScheduleSummary, bool) { + for _, schedule := range spec.RealWorldSchedules { + if schedule.ID == spec.LaunchConfig.ScheduleID { + return schedule, true + } + } + return sim.BuiltInScheduleSummary{}, false +} +func formatScheduleStartTime(minutes int) string { + minutes = ((minutes % (24 * 60)) + (24 * 60)) % (24 * 60) + return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) +} + +func parseScheduleStartTime(value string) (int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + + var hour, minute int + + if strings.Contains(value, ":") { + parts := strings.Split(value, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return 0, false + } + + var err error + hour, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, false + } + + minute, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, false + } + } else { + if len(value) > 4 { + return 0, false + } + + number, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + + switch len(value) { + case 1, 2: + hour = number + case 3: + hour = number / 100 + minute = number % 100 + case 4: + hour = number / 100 + minute = number % 100 + default: + return 0, false + } + } + + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, false + } + + return hour*60 + minute, true +} + +func adjustScheduleStartTime(minutes, adjustment int) int { + const minutesPerDay = 24 * 60 + return ((minutes+adjustment)%minutesPerDay + minutesPerDay) % minutesPerDay +} + +func scheduleStartTimeUTC(base time.Time, startMinute int, timezone string) (time.Time, error) { + location, err := time.LoadLocation(timezone) + if err != nil { + return time.Time{}, fmt.Errorf("load schedule timezone %q: %w", timezone, err) + } + + startMinute = ((startMinute % (24 * 60)) + (24 * 60)) % (24 * 60) + + // Preserve the calendar date selected by Vice's UTC weather/time picker, + // but interpret the schedule's clock time in the airport timezone. + scenarioDate := base.UTC() + localStart := time.Date( + scenarioDate.Year(), + scenarioDate.Month(), + scenarioDate.Day(), + startMinute/60, + startMinute%60, + 0, + 0, + location, + ) + + return localStart.UTC(), nil +} + +func (c *NewSimConfiguration) synchronizeScheduleStartTime(spec *server.ScenarioSpec) error { + if spec == nil || spec.LaunchConfig.TrafficSource != sim.TrafficSourceRealWorldSchedule { + return nil + } + + schedule, ok := selectedScheduleSummary(spec) + if !ok { + return fmt.Errorf( + "selected real-world schedule %q was not found", + spec.LaunchConfig.ScheduleID, + ) + } + + startTime, err := scheduleStartTimeUTC( + c.NewSimRequest.StartTime, + spec.LaunchConfig.ScheduleStartMinute, + schedule.Timezone, + ) + if err != nil { + return err + } + + c.NewSimRequest.StartTime = startTime + return nil +} + +func (c *NewSimConfiguration) drawTrafficSourceUI(spec *server.ScenarioSpec) { + lc := &spec.LaunchConfig + + imgui.Text("IFR traffic source:") + imgui.SameLine() + imgui.RadioButtonIntPtr("Random", (*int32)(&lc.TrafficSource), int32(sim.TrafficSourceRandom)) + + imgui.SameLine() + if len(spec.RealWorldSchedules) == 0 { + imgui.BeginDisabled() + } + imgui.RadioButtonIntPtr("Real World Schedule", (*int32)(&lc.TrafficSource), int32(sim.TrafficSourceRealWorldSchedule)) + if len(spec.RealWorldSchedules) == 0 { + imgui.EndDisabled() + imgui.TextDisabled("No built-in schedules are available for " + spec.PrimaryAirport + ".") + return + } + + if lc.TrafficSource != sim.TrafficSourceRealWorldSchedule { + return + } + + normalizeScheduleLaunchConfig(spec) + selected, _ := selectedScheduleSummary(spec) + + imgui.Text("Schedule:") + imgui.SameLine() + imgui.SetNextItemWidth(260) + if imgui.BeginCombo("##realWorldSchedule", selected.Name) { + for _, schedule := range spec.RealWorldSchedules { + isSelected := schedule.ID == lc.ScheduleID + if imgui.SelectableBoolV(schedule.Name, isSelected, 0, imgui.Vec2{}) { + lc.ScheduleID = schedule.ID + selected = schedule + } + if isSelected { + imgui.SetItemDefaultFocus() + } + } + imgui.EndCombo() + } + + if selected.Description != "" { + imgui.TextWrapped(selected.Description) + } + + imgui.Text("Start Time (Airport Local Time):") + imgui.SameLine() + imgui.SetNextItemWidth(100) + + if c.scheduleStartTimeText == "" { + c.scheduleStartTimeText = formatScheduleStartTime(lc.ScheduleStartMinute) + } + + if imgui.InputTextWithHint( + "##scheduleStartTime", + "14:00", + &c.scheduleStartTimeText, + 0, + nil, + ) { + if minutes, ok := parseScheduleStartTime(c.scheduleStartTimeText); ok { + lc.ScheduleStartMinute = minutes + + if err := c.synchronizeScheduleStartTime(spec); err != nil { + c.displayError = err + } else { + c.displayError = nil + } + } + } + + if _, ok := parseScheduleStartTime(c.scheduleStartTimeText); !ok { + imgui.SameLine() + imgui.TextDisabled("Enter HH:MM or HHMM") + } else { + imgui.SameLine() + imgui.TextDisabled("UTC: " + c.NewSimRequest.StartTime.UTC().Format("1504Z")) + } + arrivalPercentage := int32(lc.ScheduleArrivalPercentage) + imgui.SetNextItemWidth(260) + if imgui.SliderInt( + "Scheduled IFR arrival percentage", + &arrivalPercentage, + 0, + 100, + ) { + lc.ScheduleArrivalPercentage = int(arrivalPercentage) + } + + departurePercentage := int32(lc.ScheduleDeparturePercentage) + imgui.SetNextItemWidth(260) + if imgui.SliderInt( + "Scheduled IFR departure percentage", + &departurePercentage, + 0, + 100, + ) { + lc.ScheduleDeparturePercentage = int(departurePercentage) + } + + imgui.TextDisabled("Times use the selected airport's local time.") + imgui.TextDisabled("Departure times represent runway departures; pushback and taxi are not simulated.") + imgui.TextDisabled("IFR arrivals and departures are generated from the selected schedule.") +} + // initDefaultWindDirection computes the default wind direction range from the scenario's runways. // It calculates the average runway heading and sets a ±30 degree range around it. func (c *NewSimConfiguration) initDefaultWindDirection() { @@ -1281,6 +1534,11 @@ func (c *NewSimConfiguration) DrawConfigurationUI(p platform.Platform, config *C } imgui.Spacing() + // TRAFFIC SOURCE section + drawSectionHeader("Traffic Source") + c.drawTrafficSourceUI(c.ScenarioSpec) + imgui.Spacing() + // TRAFFIC RATES section drawSectionHeader("Traffic Rates") @@ -1293,18 +1551,46 @@ func (c *NewSimConfiguration) DrawConfigurationUI(p platform.Platform, config *C imgui.PopStyleColor() } - // Departures (collapsible) + // Scheduled IFR traffic or random IFR controls. lc := &c.ScenarioSpec.LaunchConfig - if lc.HaveDepartures() { - depRate := lc.TotalDepartureRate() - headerText := fmt.Sprintf("Departures (Total: %d/hr)###departures", int(depRate+0.5)) - if imgui.CollapsingHeaderBoolPtr(headerText, nil) { - drawDepartureUI(lc, p) - imgui.Spacing() + if lc.TrafficSource == sim.TrafficSourceRealWorldSchedule { + imgui.Text("IFR arrivals and departures are controlled by the selected schedule.") + imgui.TextDisabled(fmt.Sprintf( + "Scheduled IFR arrivals: %d%%", + lc.ScheduleArrivalPercentage, + )) + imgui.TextDisabled(fmt.Sprintf( + "Scheduled IFR departures: %d%%", + lc.ScheduleDeparturePercentage, + )) + imgui.Spacing() + } else { + if lc.HaveDepartures() { + depRate := lc.TotalDepartureRate() + headerText := fmt.Sprintf( + "Departures (Total: %d/hr)###departures", + int(depRate+0.5), + ) + if imgui.CollapsingHeaderBoolPtr(headerText, nil) { + drawDepartureUI(lc, p) + imgui.Spacing() + } + } + + if lc.HaveArrivals() { + arrRate := lc.TotalArrivalRate() + headerText := fmt.Sprintf( + "Arrivals (Total: %d/hr)###arrivals", + int(arrRate+0.5), + ) + if imgui.CollapsingHeaderBoolPtr(headerText, nil) { + drawArrivalUI(lc, p) + imgui.Spacing() + } } } - // VFR Departures (collapsible) + // VFR Departures remain independent of the IFR traffic source. if len(lc.VFRAirportRates) > 0 { var vfrRate float32 for _, rate := range lc.VFRAirportRates { @@ -1313,23 +1599,26 @@ func (c *NewSimConfiguration) DrawConfigurationUI(p platform.Platform, config *C vfrRate += r } } - headerText := fmt.Sprintf("VFR Departures (%d/hr)###vfrdepartures", int(vfrRate+0.5)) + headerText := fmt.Sprintf( + "VFR Departures (%d/hr)###vfrdepartures", + int(vfrRate+0.5), + ) if imgui.CollapsingHeaderBoolPtr(headerText, nil) { drawVFRDepartureUI(lc, p) imgui.Spacing() } } - // Arrivals (collapsible) - if lc.HaveArrivals() { - arrRate := lc.TotalArrivalRate() - headerText := fmt.Sprintf("Arrivals (Total: %d/hr)###arrivals", int(arrRate+0.5)) - if imgui.CollapsingHeaderBoolPtr(headerText, nil) { - drawArrivalUI(lc, p) - imgui.Spacing() - } - } - + // Go-around probability still applies to scheduled arrivals. + imgui.SetNextItemWidth(220) + imgui.SliderFloatV( + "Go around probability", + &lc.GoAroundRate, + 0, + 1, + "%.02f", + 0, + ) // Overflights (collapsible) if lc.HaveOverflights() { ofRate := lc.TotalOverflightRate() @@ -1378,6 +1667,18 @@ func (c *NewSimConfiguration) DrawRatesUI(p platform.Platform) bool { func (c *NewSimConfiguration) Start(config *Config) error { c.ScenarioSpec.LaunchConfig.EnableTowerGoArounds = config.EnableTowerGoArounds + if c.ScenarioSpec.LaunchConfig.TrafficSource == sim.TrafficSourceRealWorldSchedule { + minutes, ok := parseScheduleStartTime(c.scheduleStartTimeText) + if !ok { + return fmt.Errorf("invalid schedule start time %q", c.scheduleStartTimeText) + } + + c.ScenarioSpec.LaunchConfig.ScheduleStartMinute = minutes + if err := c.synchronizeScheduleStartTime(c.ScenarioSpec); err != nil { + return err + } + } + if c.newSimType == NewSimJoinRemote { // Set the privileged flag from the main config c.joinRequest.Privileged = c.Privileged diff --git a/cmd/vice/simconfig_schedule_test.go b/cmd/vice/simconfig_schedule_test.go new file mode 100644 index 000000000..4d3557d63 --- /dev/null +++ b/cmd/vice/simconfig_schedule_test.go @@ -0,0 +1,153 @@ +// simconfig_schedule_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package main + +import ( + "testing" + "time" + + "github.com/mmp/vice/server" + "github.com/mmp/vice/sim" +) + +func TestNormalizeScheduleLaunchConfig(t *testing.T) { + spec := &server.ScenarioSpec{ + PrimaryAirport: "KMSP", + RealWorldSchedules: []sim.BuiltInScheduleSummary{ + {ID: "development-test", Name: "Development Test"}, + }, + LaunchConfig: sim.LaunchConfig{ + TrafficSource: sim.TrafficSourceRealWorldSchedule, + ScheduleStartMinute: 2000, + ScheduleArrivalPercentage: -5, + ScheduleDeparturePercentage: 150, + }, + } + + normalizeScheduleLaunchConfig(spec) + + if got, want := spec.LaunchConfig.ScheduleID, "development-test"; got != want { + t.Fatalf("ScheduleID = %q, want %q", got, want) + } + if got, want := spec.LaunchConfig.ScheduleStartMinute, 1439; got != want { + t.Fatalf("ScheduleStartMinute = %d, want %d", got, want) + } + if got, want := spec.LaunchConfig.ScheduleArrivalPercentage, 0; got != want { + t.Fatalf("ScheduleArrivalPercentage = %d, want %d", got, want) + } + + if got, want := spec.LaunchConfig.ScheduleDeparturePercentage, 100; got != want { + t.Fatalf("ScheduleDeparturePercentage = %d, want %d", got, want) + } +} + +func TestNormalizeScheduleLaunchConfigWithoutSchedules(t *testing.T) { + spec := &server.ScenarioSpec{ + LaunchConfig: sim.LaunchConfig{ + TrafficSource: sim.TrafficSourceRealWorldSchedule, + ScheduleID: "missing", + }, + } + + normalizeScheduleLaunchConfig(spec) + + if spec.LaunchConfig.TrafficSource != sim.TrafficSourceRandom { + t.Fatalf("TrafficSource = %v, want random", spec.LaunchConfig.TrafficSource) + } + if spec.LaunchConfig.ScheduleID != "" { + t.Fatalf("ScheduleID = %q, want empty", spec.LaunchConfig.ScheduleID) + } +} +func TestScheduleStartTimeUTCSummer(t *testing.T) { + base := time.Date(2026, time.July, 14, 3, 25, 0, 0, time.UTC) + + got, err := scheduleStartTimeUTC(base, 14*60, "America/Chicago") + if err != nil { + t.Fatalf("scheduleStartTimeUTC: %v", err) + } + + want := time.Date(2026, time.July, 14, 19, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("scheduleStartTimeUTC = %s, want %s", got, want) + } +} + +func TestScheduleStartTimeUTCWinter(t *testing.T) { + base := time.Date(2026, time.January, 14, 3, 25, 0, 0, time.UTC) + + got, err := scheduleStartTimeUTC(base, 14*60, "America/Chicago") + if err != nil { + t.Fatalf("scheduleStartTimeUTC: %v", err) + } + + want := time.Date(2026, time.January, 14, 20, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("scheduleStartTimeUTC = %s, want %s", got, want) + } +} + +func TestScheduleStartTimeUTCPreservesSelectedScenarioDate(t *testing.T) { + // Even though 02:00Z on July 15 is still July 14 in Chicago, + // the selected Vice scenario date remains July 15. + base := time.Date(2026, time.July, 15, 2, 0, 0, 0, time.UTC) + + got, err := scheduleStartTimeUTC(base, 23*60, "America/Chicago") + if err != nil { + t.Fatalf("scheduleStartTimeUTC: %v", err) + } + + // 23:00 CDT on July 15 is 04:00Z on July 16. + want := time.Date(2026, time.July, 16, 4, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("scheduleStartTimeUTC = %s, want %s", got, want) + } +} + +func TestScheduleStartTimeUTCRejectsUnknownTimezone(t *testing.T) { + _, err := scheduleStartTimeUTC( + time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), + 14*60, + "Not/A_Timezone", + ) + if err == nil { + t.Fatal("expected invalid timezone error") + } +} + +func TestParseScheduleStartTime(t *testing.T) { + tests := map[string]int{ + "1400": 14 * 60, + "14:00": 14 * 60, + "9:30": 9*60 + 30, + "0930": 9*60 + 30, + "9": 9 * 60, + } + + for input, want := range tests { + got, ok := parseScheduleStartTime(input) + if !ok { + t.Errorf("parseScheduleStartTime(%q) rejected valid time", input) + continue + } + if got != want { + t.Errorf("parseScheduleStartTime(%q) = %d, want %d", input, got, want) + } + } +} + +func TestParseScheduleStartTimeRejectsInvalidValues(t *testing.T) { + for _, input := range []string{ + "", + "25:00", + "14:99", + "2400", + "abcd", + "12:30:00", + } { + if _, ok := parseScheduleStartTime(input); ok { + t.Errorf("parseScheduleStartTime(%q) accepted invalid time", input) + } + } +} diff --git a/resources/schedules/KMSP/summer_weekday.csv b/resources/schedules/KMSP/summer_weekday.csv new file mode 100644 index 000000000..15ca19a72 --- /dev/null +++ b/resources/schedules/KMSP/summer_weekday.csv @@ -0,0 +1,976 @@ +callsign,origin,destination,aircraft_type,time,cargo +AAL1251,KMSP,KDFW,A321,05:00,false +SWA2520,KMSP,KMDW,B38M,05:00,false +DAL1062,KMSP,KATL,B753,05:20,false +SWA3658,KMSP,KBWI,B738,05:25,false +UAL2041,KMSP,KDEN,B738,05:45,false +JIA5488,KMSP,KPHL,CRJ9,05:45,false +SWA1525,KMSP,KDEN,B38M,05:50,false +SCX1655,KMSP,KCMH,B738,06:00,false +UAL1246,KMSP,KEWR,B739,06:00,false +DAL1730,KMSP,KMCO,B739,06:00,false +AAL1870,KMSP,KMIA,A321,06:00,false +UAL307,KMSP,KORD,B739,06:00,false +SCX391,KMSP,KSFO,B738,06:00,false +AAL2150,KMSP,KDFW,B738,06:02,false +ASA387,KMSP,KSEA,B39M,06:03,false +DAL1461,KMSP,KDTW,A321,06:04,false +RPA4444,KMSP,KORD,E75L,06:04,false +SCX421,KMSP,KLAX,B738,06:05,false +SCX281,KMSP,KSEA,B738,06:10,false +AAL894,KMSP,KCLT,A319,06:19,false +SCX341,KMSP,KMCO,B738,06:25,false +SCX1981,KMSP,KBDL,B738,06:30,false +JIA5312,KMSP,KDCA,CRJ7,06:30,false +SCX265,KMSP,KPVD,B738,06:35,false +SCX213,KMSP,KTPA,B738,06:40,false +DAL2724,KMSP,KBNA,A321,06:55,false +SCX251,KMSP,KBOS,B738,06:55,false +DAL1827,KMSP,KCLT,BCS1,06:55,false +DAL2969,KMSP,KJFK,BCS1,06:55,false +EDV4867,KMSP,KMCI,CRJ7,06:55,false +EDV4712,KMSP,KMDW,CRJ7,06:55,false +DAL1766,KMSP,KPWM,B739,06:55,false +DAL1418,KMSP,KSEA,B739,06:55,false +DAL1039,KMSP,KATL,B739,07:00,false +SKW3950,KMSP,KAUS,E75L,07:00,false +DAL2649,KMSP,KBOS,A21N,07:00,false +DAL1675,KMSP,KDEN,A21N,07:00,false +SKW4103,KMSP,KMSN,CRJ9,07:00,false +DAL1544,KMSP,KORD,BCS3,07:00,false +DAL2783,KMSP,KRDU,A21N,07:00,false +SCX1835,KMSP,KSAT,B738,07:00,false +UAL1808,KMSP,KSFO,A319,07:00,false +SWA1528,KMSP,KBNA,B737,07:05,false +SCX233,KMSP,KEWR,B738,07:05,false +DAL2656,KMSP,KIAH,BCS1,07:05,false +DAL2261,KMSP,KLAS,B739,07:05,false +DAL2719,KMSP,KLGA,A321,07:05,false +EDV5286,KMSP,KSTL,CRJ9,07:05,false +DAL2007,KMSP,KBDL,A319,07:06,false +DAL2110,KMSP,KLAX,A21N,07:06,false +DAL2847,KMSP,KCLE,BCS1,07:08,false +DAL2357,KMSP,KBWI,BCS1,07:10,false +DAL2772,KMSP,KCMH,BCS1,07:10,false +DAL2864,KMSP,KEWR,A320,07:10,false +DAL2688,KMSP,KPHL,BCS1,07:10,false +SCX1419,KMSP,KRIC,B738,07:10,false +DAL954,KMSP,KCVG,A320,07:14,false +SCX567,KMSP,KJFK,B738,07:15,false +DAL2301,KMSP,KTPA,A321,07:15,false +SKW3699,KMSP,KIAD,E75L,07:17,false +SCX383,KMSP,KRSW,B738,07:20,false +SCX489,KMSP,KGPI,B738,07:25,false +DAL1047,KMSP,KPHX,B739,07:26,false +DAL845,KMSP,KPIT,BCS1,07:29,false +SCX1907,KMSP,KDTW,B738,07:30,false +UAL567,KMSP,KORD,B739,07:34,false +SCX651,KMSP,KDEN,B738,07:35,false +UAL2460,KMSP,KIAH,A319,07:35,false +DAL2958,KMSP,KDCA,BCS3,07:36,false +ASA1541,KMSP,PANC,B39M,07:40,false +SCX1775,KMSP,KPHL,B738,07:40,false +SKW4466,KMSP,KMKE,CRJ9,07:41,false +AAL3143,KMSP,KPHX,B38M,07:41,false +SWA2973,KMSP,KSTL,B737,07:45,false +DAL2972,KMSP,KDFW,BCS1,07:54,false +SCX1821,KMSP,KPWM,B738,07:55,false +SCX193,KMSP,KBWI,B738,08:00,false +JZA716,KMSP,CYYZ,CRJ9,08:00,false +AAL1395,KMSP,KDFW,B738,08:01,false +EDV4901,KMSP,KBTV,CRJ9,08:04,false +SCX1057,KMSP,KBUF,B738,08:05,false +SCX325,KMSP,KJAX,B738,08:10,false +DAL2628,KMSP,KSLC,B739,08:10,false +SCX103,KMSP,KLAS,B739,08:20,false +SKW3990,KMSP,KGRR,CRJ9,08:25,false +SCX667,KMSP,KIAD,B738,08:25,false +SKW3868,KMSP,CYYZ,CRJ9,08:25,false +DAL1052,KMSP,KATL,A321,08:26,false +DAL555,KMSP,PANC,B752,08:50,false +DAL2304,KMSP,KBOI,A320,08:50,false +DAL673,KMSP,KFLL,B753,08:50,false +DAL2456,KMSP,KIND,BCS1,08:50,false +EDV5142,KMSP,KOKC,CRJ7,08:50,false +SKW3831,KMSP,KOMA,CRJ9,08:50,false +DAL2121,KMSP,KSAN,A321,08:51,false +DAL1226,KMSP,KBZN,B739,08:55,false +DAL1881,KMSP,MMUN,A321,08:55,false +DAL2849,KMSP,KDEN,A321,08:55,false +EDV4998,KMSP,KDSM,CRJ9,08:55,false +SKW4329,KMSP,KGFK,CRJ7,08:55,false +DAL2129,KMSP,KMIA,B739,08:55,false +DAL3188,KMSP,KSTL,BCS1,08:55,false +DAL2181,KMSP,KAVL,BCS1,09:00,false +SKW4066,KMSP,KBIS,CRJ9,09:00,false +DAL1433,KMSP,KDTW,B752,09:00,false +SKW4239,KMSP,KESC,CRJ7,09:00,false +EDV5077,KMSP,KFAR,CRJ7,09:00,false +DAL2667,KMSP,KSAT,BCS1,09:00,false +EDV4988,KMSP,KSDF,CRJ7,09:00,false +UAL1891,KMSP,KDEN,B738,09:05,false +SKW3561,KMSP,KFSD,E75L,09:05,false +DAL2415,KMSP,KPDX,B752,09:05,false +DAL2400,KMSP,KRSW,B739,09:05,false +DAL868,KMSP,KSEA,B739,09:05,false +DAL2482,KMSP,CYVR,B739,09:05,false +SKW4089,KMSP,KCOS,CRJ9,09:07,false +SKW4229,KMSP,KHIB,CRJ7,09:10,false +SKW4245,KMSP,KINL,CRJ7,09:10,false +SKW3989,KMSP,KMEM,E75L,09:10,false +SKW4305,KMSP,KRHI,CRJ7,09:10,false +SKW3536,KMSP,KXNA,E75L,09:10,false +DAL2262,KMSP,KLAS,B752,09:13,false +DAL2895,KMSP,KEWR,BCS1,09:14,false +SKW4273,KMSP,KBRD,CRJ7,09:15,false +DAL611,KMSP,MMMX,A319,09:19,false +DAL2102,KMSP,KLAX,B753,09:20,false +DAL2078,KMSP,KSFO,A321,09:24,false +DAL1208,KMSP,KMCO,B753,09:27,false +DAL2208,KMSP,KPHX,B739,09:28,false +EDV4951,KMSP,KATW,CRJ7,09:32,false +SKW3971,KMSP,KRAP,CRJ9,09:33,false +SCX471,KMSP,PANC,B738,09:40,false +UAL1929,KMSP,KORD,B738,09:47,false +DAL2057,KMSP,KCLE,BCS1,10:00,false +DAL1567,KMSP,KORD,BCS3,10:00,false +EDV5063,KMSP,KRIC,CRJ7,10:00,false +SKW4261,KMSP,KXWA,CRJ7,10:00,false +DAL2970,KMSP,KDFW,A320,10:03,false +SKW3666,KMSP,KDLH,CRJ9,10:04,false +DAL2655,KMSP,KBOS,A321,10:05,false +DAL2764,KMSP,KLGA,A321,10:05,false +DAL561,KMSP,KMKE,BCS1,10:05,false +DAL2383,KMSP,KSJC,A320,10:05,false +DAL2696,KMSP,KTPA,B739,10:05,false +EDV4705,KMSP,KRST,CRJ9,10:07,false +SKW3920,KMSP,KGRB,CRJ9,10:09,false +DAL2062,KMSP,KAUS,A320,10:10,false +DAL312,KMSP,PHNL,A333,10:10,false +SKW4144,KMSP,KMOT,CRJ9,10:10,false +DAL1224,KMSP,KPVD,A319,10:10,false +SKW4102,KMSP,KMSN,CRJ9,10:12,false +DAL2550,KMSP,KCMH,BCS1,10:14,false +UAL1637,KMSP,KEWR,A319,10:14,false +EDV4899,KMSP,KCID,CRJ7,10:16,false +SKW3683,KMSP,CYWG,E75L,10:18,false +DAL1608,KMSP,KMCI,BCS1,10:20,false +DAL2521,KMSP,KRDU,BCS3,10:21,false +DAL2673,KMSP,KIAH,BCS3,10:24,false +DAL1701,KMSP,KSMF,B739,10:24,false +SKW3995,KMSP,KTVC,CRJ9,10:24,false +DAL2983,KMSP,KDCA,A319,10:25,false +SWA4345,KMSP,KMDW,B737,10:25,false +DAL2463,KMSP,KCHS,A320,10:30,false +DAL2819,KMSP,KCLT,BCS1,10:30,false +DAL1853,KMSP,KGEG,A320,10:30,false +ENY4162,KMSP,KORD,E170,10:31,false +DAL1046,KMSP,KATL,A321,10:32,false +AAL2152,KMSP,KDFW,B738,10:34,false +DAL2462,KMSP,KVPS,B739,10:35,false +SKW3838,KMSP,KOMA,E75L,10:39,false +DAL121,KMSP,RJTT,A359,10:40,false +LYM5111,KMSP,KIWD,E145,10:45,false +SWA342,KMSP,KAUS,B737,10:55,false +AAL2154,KMSP,KCLT,A319,10:56,false +DAL2927,KMSP,KDEN,A321,11:00,false +DAL2167,KMSP,KPDX,A321,11:00,false +DAL2074,KMSP,KSFO,B739,11:00,false +DAL2081,KMSP,KSNA,BCS1,11:00,false +SCX1951,KMSP,CYVR,B738,11:00,false +DAL2679,KMSP,KPHL,BCS3,11:09,false +EDV4992,KMSP,KBIS,CRJ9,11:10,false +DAL2238,KMSP,KLAS,B752,11:10,false +DAL889,KMSP,KSEA,B753,11:10,false +SKW3771,KMSP,KFAR,E75L,11:14,false +DAL2487,KMSP,CYYC,A321,11:20,false +EDV4820,KMSP,KMDW,CRJ7,11:28,false +DAL2119,KMSP,KLAX,A21N,11:30,false +UAL576,KMSP,KORD,B737,11:32,false +DAL2274,KMSP,KPHX,B752,11:32,false +UAL2232,KMSP,KDEN,A320,11:34,false +SWA4346,KMSP,KDEN,B738,11:35,false +SKW3534,KMSP,KFSD,CRJ9,11:38,false +DAL2125,KMSP,KSAN,A321,11:38,false +DAL2982,KMSP,KBOI,A320,11:39,false +DAL1241,KMSP,CYVR,A321,11:45,false +UAL1795,KMSP,KIAH,B39M,12:05,false +RPA3695,KMSP,KIAD,E75L,12:17,false +DAL2437,KMSP,KGEG,B739,12:19,false +SWA3743,KMSP,KMDW,B737,12:20,false +DAL2651,KMSP,KSLC,B739,12:29,false +DAL2575,KMSP,KGPI,A320,12:35,false +DAL1048,KMSP,KATL,B752,12:45,false +EDV4680,KMSP,KHPN,CRJ9,12:45,false +DAL2225,KMSP,KMCO,B753,12:45,false +DAL362,KMSP,KMSO,BCS1,12:45,false +DAL2695,KMSP,KMSY,A321,12:45,false +DAL402,KMSP,KRSW,B739,12:45,false +DAL2574,KMSP,KSAV,BCS1,12:45,false +DAL2843,KMSP,KBNA,B739,12:50,false +DAL2558,KMSP,KBOS,A321,12:50,false +DAL3113,KMSP,KCVG,BCS1,12:50,false +ENY3796,KMSP,KDCA,E75L,12:50,false +EDV5490,KMSP,KDSM,CRJ7,12:51,false +DAL2661,KMSP,KJFK,BCS1,12:54,false +DAL1519,KMSP,KDTW,B739,12:55,false +SKW3865,KMSP,KGRR,CRJ9,12:55,false +DAL2910,KMSP,KIND,B738,12:55,false +SKW4032,KMSP,KMSN,E75L,12:55,false +SKW3967,KMSP,KRAP,CRJ9,12:55,false +DAL2327,KMSP,KMYR,BCS1,12:56,false +DAL2392,KMSP,KJAX,A320,12:57,false +SKW4262,KMSP,KABR,CRJ7,13:00,false +SKW4283,KMSP,KBJI,CRJ7,13:00,false +DAL2756,KMSP,KLGA,B739,13:00,false +SKW4284,KMSP,KXWA,CRJ7,13:00,false +DAL1651,KMSP,KLAX,A21N,13:02,false +EDV4774,KMSP,KFAR,CRJ7,13:04,false +SKW4242,KMSP,KCIU,CRJ7,13:05,false +SKW4236,KMSP,KHIB,CRJ7,13:05,false +SKW5570,KMSP,KORD,E75L,13:08,false +JIA5658,KMSP,KPHL,CRJ9,13:08,false +SKW4330,KMSP,KGFK,CRJ7,13:10,false +SKW4266,KMSP,KINL,CRJ7,13:10,false +JZA850,KMSP,CYUL,CRJ9,13:10,false +EDV4971,KMSP,KPIT,CRJ9,13:12,false +FFT3199,KMSP,KDEN,A21N,13:16,false +DAL2946,KMSP,KBZN,B739,13:17,false +DAL2264,KMSP,KTPA,A321,13:19,false +SKW3595,KMSP,KICT,CRJ9,13:23,false +DAL1533,KMSP,KORD,BCS1,13:25,false +EDV5335,KMSP,KTYS,CRJ7,13:27,false +SCX919,KMSP,KRDU,B738,13:30,false +SKW3823,KMSP,CYWG,E75L,13:31,false +DAL171,KMSP,RKSI,A359,13:35,false +UAL1305,KMSP,KDEN,A320,13:36,false +SKW4167,KMSP,KSTL,CRJ9,13:36,false +AAL2153,KMSP,KCLT,A320,13:39,false +SCX345,KMSP,KMCO,B738,13:40,false +DAL1613,KMSP,KMCI,BCS1,13:45,false +SCX1947,KMSP,KCLT,B738,13:50,false +SWA2967,KMSP,KDEN,B38M,13:50,false +LYM5110,KMSP,KTVF,E145,13:50,false +DAL2006,KMSP,KMKE,BCS1,13:54,false +ENY3719,KMSP,KORD,E170,14:02,false +DAL2866,KMSP,KEWR,BCS1,14:10,false +SCX605,KMSP,KPHX,B738,14:10,false +SKW3861,KMSP,KATW,CRJ9,14:20,false +DAL1042,KMSP,KAUS,A21N,14:20,false +SCX633,KMSP,KBNA,B738,14:20,false +SKW3843,KMSP,KGRB,CRJ9,14:20,false +SKW4314,KMSP,KSAW,CRJ7,14:20,false +DAL2699,KMSP,KRDU,B739,14:20,false +DAL2580,KMSP,KSAT,BCS1,14:20,false +EDV5017,KMSP,KSDF,CRJ7,14:20,false +FFT3046,KMSP,KATL,A20N,14:24,false +DAL2594,KMSP,KPHL,BCS1,14:25,false +DAL2658,KMSP,KSLC,A21N,14:25,false +DAL2285,KMSP,KBDL,BCS1,14:26,false +DAL1025,KMSP,KDEN,A321,14:27,false +EDV4906,KMSP,KCID,CRJ7,14:28,false +SKW3783,KMSP,KIAD,CRJ9,14:30,false +SCX105,KMSP,KLAS,B738,14:30,false +SKW4012,KMSP,KXNA,E75L,14:34,false +SKW3538,KMSP,KFSD,CRJ9,14:36,false +DAL2963,KMSP,KDCA,BCS1,14:37,false +EDV4982,KMSP,KCWA,CRJ7,14:39,false +SKW3686,KMSP,KDLH,E75L,14:40,false +SCX407,KMSP,KSAN,B738,14:40,false +DAL2565,KMSP,KBWI,BCS3,14:41,false +DAL2644,KMSP,KIAH,BCS3,14:45,false +SKW3851,KMSP,KRAP,CRJ9,14:45,false +DAL2971,KMSP,KDFW,A320,14:47,false +AAL1750,KMSP,KPHX,B738,14:47,false +SCX425,KMSP,KLAX,B738,14:50,false +DAL1542,KMSP,KLAS,B739,14:51,false +DAL2284,KMSP,KPHX,B739,14:54,false +SKW4130,KMSP,KMOT,CRJ9,14:55,false +SCX1701,KMSP,KSLC,B738,14:55,false +WJA1531,KMSP,CYEG,B737,14:55,false +ASA555,KMSP,KSEA,B739,14:56,false +SCX285,KMSP,KSEA,B738,15:00,false +KLM656,KMSP,EHAM,B789,15:05,false +SCX395,KMSP,KSFO,B738,15:05,false +WJA1811,KMSP,CYYC,B38M,15:05,false +DAL756,KMSP,KSFO,A21N,15:07,false +SCX655,KMSP,KDEN,B738,15:10,false +AAL2145,KMSP,KDFW,B738,15:11,false +DAL2591,KMSP,KBOS,A21N,15:15,false +DAL2821,KMSP,KCLT,A319,15:15,false +SCX295,KMSP,KPDX,B738,15:15,false +SKW4473,KMSP,KRST,E75L,15:15,false +DAL877,KMSP,KSEA,A321,15:15,false +DAL3185,KMSP,KSTL,BCS1,15:15,false +SKW4147,KMSP,CYWG,E75L,15:15,false +UAL1531,KMSP,KORD,A319,15:16,false +SKW4270,KMSP,KIMT,CRJ7,15:20,false +DAL1630,KMSP,KMCI,BCS1,15:20,false +DAL1306,KMSP,KORD,BCS3,15:20,false +UAL1367,KMSP,KSFO,A320,15:20,false +SKW4080,KMSP,CYYZ,CRJ9,15:20,false +DAL1749,KMSP,KBUF,A320,15:22,false +SKW3935,KMSP,KBIS,E75L,15:25,false +SCX499,KMSP,KIND,B738,15:25,false +SKW3709,KMSP,KPIT,CRJ9,15:25,false +DAL2612,KMSP,KBNA,A321,15:29,false +SKW3948,KMSP,KOMA,CRJ9,15:29,false +DAL2732,KMSP,KLGA,B739,15:30,false +DAL1885,KMSP,KMCO,B752,15:30,false +EDV4915,KMSP,KTVC,CRJ9,15:30,false +DAL2113,KMSP,KCMH,BCS1,15:35,false +DAL1449,KMSP,KCLE,BCS1,15:39,false +EDV4913,KMSP,KDSM,CRJ9,15:40,false +SCX261,KMSP,KORD,B738,15:40,false +DAL1456,KMSP,KIND,B739,15:45,false +DAL2114,KMSP,KLAX,B739,15:45,false +SKW4063,KMSP,KMEM,E75L,15:45,false +DAL703,KMSP,KCVG,B739,15:50,false +SCX1879,KMSP,KRAP,B738,15:50,false +WEN3817,KMSP,CYQR,DH8D,15:50,false +OCN53,KMSP,EDDF,A333,15:55,false +SCX1925,KMSP,KTVC,B738,15:55,false +JZA720,KMSP,CYYZ,CRJ9,15:55,false +DAL1028,KMSP,KATL,A321,15:58,false +DAL2008,KMSP,KPDX,B739,15:58,false +DAL160,KMSP,EHAM,A359,16:00,false +SWA2962,KMSP,KBNA,B38M,16:00,false +SCX1913,KMSP,KGRR,B738,16:00,false +DAL1555,KMSP,KGRR,BCS1,16:02,false +RPA4727,KMSP,KORD,E75L,16:03,false +SCX219,KMSP,KCVG,B738,16:05,false +EDV4923,KMSP,KSDF,CRJ7,16:13,false +AAL2157,KMSP,KPHL,A319,16:14,false +DAL1476,KMSP,KDTW,A321,16:15,false +SWA2971,KMSP,KMDW,B738,16:15,false +DAL152,KMSP,LFPG,A333,16:30,false +SCX1491,KMSP,KCMH,B738,16:35,false +EDV4714,KMSP,KMDW,CRJ7,16:40,false +SKW4331,KMSP,KGFK,CRJ7,16:45,false +UAL2272,KMSP,KDEN,A319,16:51,false +EDV5331,KMSP,KMSN,CRJ7,16:51,false +ASA510,KMSP,KSEA,B739,16:57,false +SKW4462,KMSP,KMKE,E75L,16:58,false +EDV4782,KMSP,KFAR,CRJ7,16:59,false +RPA3678,KMSP,KIAD,E75L,17:05,false +FFT3223,KMSP,KDFW,A20N,17:09,false +UAL658,KMSP,KORD,A320,17:13,false +DAL1036,KMSP,KATL,B739,17:18,false +LYM5120,KMSP,KTVF,E145,17:30,false +DAL1435,KMSP,KDTW,A320,17:35,false +SWA2974,KMSP,KSTL,B738,17:40,false +UAL283,KMSP,KIAH,A319,17:42,false +DAL2130,KMSP,KORD,BCS1,17:45,false +JIA5416,KMSP,KDCA,CRJ7,17:46,false +AAL2151,KMSP,KCLT,A319,17:47,false +SKW4301,KMSP,KBRD,CRJ7,17:55,false +SKW4233,KMSP,KRHI,CRJ7,17:55,false +SWA2963,KMSP,KBWI,B38M,18:00,false +RPA4465,KMSP,KORD,E75L,18:02,false +DAL377,KMSP,PANC,B752,18:10,false +DAL1502,KMSP,KBOS,A321,18:10,false +DAL1055,KMSP,PAFA,B739,18:10,false +EDV5244,KMSP,KJFK,CRJ9,18:10,false +SKW4464,KMSP,KMKE,CRJ9,18:10,false +SKW3791,KMSP,KOKC,E75L,18:10,false +DAL730,KMSP,KABQ,BCS1,18:11,false +DAL2607,KMSP,KSLC,B739,18:13,false +SKW4299,KMSP,KATY,CRJ7,18:15,false +DAL2307,KMSP,KBIS,BCS1,18:15,false +DAL2278,KMSP,KFAR,A320,18:15,false +DAL2385,KMSP,KTPA,B752,18:15,false +ASA1434,KMSP,KPDX,B738,18:17,false +SKW3912,KMSP,KDLH,CRJ9,18:20,false +SKW4143,KMSP,KMOT,CRJ9,18:20,false +DAL2560,KMSP,KRNO,B738,18:20,false +UAL467,KMSP,KDEN,B738,18:23,false +SKW4024,KMSP,KOMA,CRJ9,18:25,false +DAL762,KMSP,KPSC,BCS1,18:25,false +DAL2107,KMSP,KSNA,BCS3,18:28,false +EIN88,KMSP,EIDW,A21N,18:30,false +SKW3578,KMSP,KFSD,CRJ9,18:30,false +DAL2087,KMSP,KSJC,BCS1,18:30,false +DAL2151,KMSP,KPDX,A21N,18:34,false +DAL2962,KMSP,KDCA,BCS3,18:35,false +DAL258,KMSP,EIDW,A332,18:35,false +DAL2773,KMSP,KSAT,BCS3,18:35,false +DAL1026,KMSP,KATL,A321,18:37,false +DAL2683,KMSP,KIAH,BCS1,18:40,false +DAL2485,KMSP,CYYC,A320,18:40,false +SKW5390,KMSP,KORD,E75L,18:41,false +DAL2977,KMSP,CYVR,A320,18:42,false +DAL2931,KMSP,KDEN,B739,18:45,false +DAL574,KMSP,KSMF,B739,18:45,false +DAL2297,KMSP,KPHX,B739,18:48,false +SKW3925,KMSP,KJAC,E75L,18:50,false +DAL2145,KMSP,KSAN,A321,18:50,false +DAL2809,KMSP,KSFO,A321,18:50,false +DAL2187,KMSP,KMIA,B739,18:53,false +DAL881,KMSP,KSEA,B739,18:55,false +DAL2246,KMSP,KLAS,B753,18:59,false +DAL2122,KMSP,KLAX,B753,18:59,false +SWA3063,KMSP,KDEN,B38M,19:00,false +AAL2148,KMSP,KDFW,B738,19:15,false +SWA4115,KMSP,KMDW,B738,19:15,false +DAL2268,KMSP,KRSW,A321,19:29,false +ICE656,KMSP,BIKF,B38M,19:30,false +DAL2749,KMSP,KAUS,A320,19:45,false +DAL2918,KMSP,KBWI,B739,19:45,false +DAL2309,KMSP,KMCO,B753,19:45,false +EDV5527,KMSP,KTYS,CRJ9,19:45,false +DAL1484,KMSP,KDTW,A320,19:46,false +DAL1032,KMSP,KATL,B752,19:49,false +DAL2967,KMSP,KDFW,A320,19:50,false +SKW4129,KMSP,KIAD,E75L,19:50,false +EDV5253,KMSP,KDSM,CRJ7,19:54,false +SKW3780,KMSP,KFWA,CRJ9,19:54,false +DAL162,KMSP,EHAM,A333,19:55,false +EDV5518,KMSP,KORF,CRJ7,19:55,false +DAL260,KMSP,BIKF,B752,19:58,false +DAL2733,KMSP,KLGA,B739,19:58,false +SKW3886,KMSP,KSBN,CRJ9,19:59,false +ASA532,KMSP,KSEA,B739,19:59,false +DAL2871,KMSP,KBDL,A321,20:00,false +AFR89,KMSP,LFPG,B772,20:00,false +DAL2097,KMSP,KSYR,A319,20:00,false +SKW3866,KMSP,CYYZ,CRJ9,20:02,false +SKW4035,KMSP,KMEM,E75L,20:05,false +DAL3075,KMSP,KPIT,BCS1,20:05,false +EDV5489,KMSP,CYUL,CRJ7,20:05,false +EDV4934,KMSP,KCWA,CRJ9,20:06,false +DAL2614,KMSP,KBOS,B752,20:09,false +SKW4264,KMSP,KABR,CRJ7,20:10,false +SKW3541,KMSP,KICT,CRJ9,20:10,false +DAL2929,KMSP,KCLT,BCS1,20:14,false +DAL1356,KMSP,KCMH,A320,20:15,false +DAL2846,KMSP,KEWR,BCS1,20:15,false +DAL1563,KMSP,KORD,BCS1,20:15,false +EDV5530,KMSP,KRIC,CRJ7,20:16,false +DAL1144,KMSP,KBNA,B739,20:20,false +DAL1469,KMSP,KCVG,A321,20:20,false +DAL298,KMSP,LIRF,A339,20:20,false +SKW4468,KMSP,KMKE,E75L,20:20,false +DAL1051,KMSP,KSLC,A321,20:20,false +DAL2703,KMSP,KPHL,B739,20:25,false +DAL2932,KMSP,KRDU,A321,20:25,false +DAL2961,KMSP,KDCA,A319,20:30,false +SKW4003,KMSP,KGRB,CRJ9,20:30,false +SKW4289,KMSP,KXWA,CRJ7,20:30,false +EDV5070,KMSP,KSDF,CRJ7,20:48,false +DAL1392,KMSP,KCLE,BCS1,20:51,false +SCX429,KMSP,KLAX,B738,20:55,false +DAL1657,KMSP,KMCI,BCS1,21:30,false +SKW4014,KMSP,KOMA,CRJ9,21:30,false +DAL2391,KMSP,KPDX,A21N,21:38,false +SKW3834,KMSP,KBIS,E75L,21:40,false +DAL2488,KMSP,KBOI,A320,21:40,false +EDV5290,KMSP,KCID,CRJ7,21:40,false +DAL1711,KMSP,KGRR,BCS3,21:40,false +DAL674,KMSP,KIND,B739,21:40,false +DAL2089,KMSP,KSFO,B739,21:40,false +EDV4920,KMSP,KATW,CRJ9,21:45,false +SKW4291,KMSP,KBJI,CRJ7,21:45,false +SKW4332,KMSP,KGFK,CRJ7,21:45,false +SKW3714,KMSP,KRAP,CRJ9,21:45,false +DAL2146,KMSP,KSAN,B739,21:47,false +SKW3907,KMSP,KDLH,E75L,21:49,false +DAL164,KMSP,EHAM,A339,21:50,false +SKW4154,KMSP,KMOT,CRJ9,21:50,false +DAL1609,KMSP,KSTL,BCS1,21:50,false +DAL2610,KMSP,KMSO,A320,21:52,false +SKW3656,KMSP,CYWG,E75L,21:54,false +EDV4907,KMSP,KRST,CRJ7,21:55,false +DAL2418,KMSP,KGEG,B739,21:59,false +DAL2103,KMSP,KLAX,A21N,21:59,false +DAL1599,KMSP,KMSN,B739,21:59,false +DAL1694,KMSP,KFSD,A319,22:05,false +DAL505,KMSP,KSEA,B753,22:08,false +DAL411,KMSP,PANC,B752,22:13,false +DAL1705,KMSP,KDEN,A321,22:15,false +FFT1149,KMSP,KDEN,A20N,22:15,false +DAL2941,KMSP,KBIL,A321,22:20,false +DAL2954,KMSP,KBZN,B739,22:23,false +DAL2295,KMSP,KFAR,A321,22:46,false +DAL10,KMSP,EGLL,A339,23:10,false +DAL2359,KPDX,KMSP,A21N,05:49,false +SKW3930,KDLH,KMSP,E75L,06:02,false +EDV4702,KRST,KMSP,CRJ7,06:04,false +DAL2084,KLAX,KMSP,B753,06:07,false +DAL2789,KSFO,KMSP,B739,06:09,false +DAL2237,KFAR,KMSP,A320,06:10,false +SKW3840,KFSD,KMSP,CRJ9,06:14,false +SKW4328,KGFK,KMSP,CRJ7,06:21,false +SKW3914,KBIS,KMSP,E75L,06:31,false +SKW4158,KMOT,KMSP,CRJ9,06:35,false +DAL1357,KCLE,KMSP,BCS1,07:04,false +DAL2752,KCVG,KMSP,A321,07:05,false +DAL1274,KMSN,KMSP,B739,07:08,false +EDV4995,KATW,KMSP,CRJ9,07:17,false +DAL2791,KPHL,KMSP,B739,07:20,false +SKW3864,KOMA,KMSP,CRJ9,07:21,false +SKW4292,KRHI,KMSP,CRJ7,07:31,false +SKW4140,KGRB,KMSP,CRJ9,07:32,false +DAL2913,KGRR,KMSP,BCS3,07:32,false +DAL1193,KDCA,KMSP,A319,07:35,false +SKW4336,KBRD,KMSP,CRJ7,07:40,false +SKW4296,KXWA,KMSP,CRJ7,07:41,false +SKW4238,KABR,KMSP,CRJ7,07:44,false +DAL2538,KRDU,KMSP,A321,07:46,false +DAL2757,KIND,KMSP,B739,07:49,false +SKW4303,KBJI,KMSP,CRJ7,07:50,false +DAL1746,KDTW,KMSP,B752,07:53,false +SKW3996,KIAD,KMSP,E75L,07:53,false +DAL2510,KLGA,KMSP,B739,07:53,false +DAL2872,KBWI,KMSP,B739,07:54,false +DAL1635,KMCI,KMSP,BCS1,07:54,false +SKW4073,CYYZ,KMSP,CRJ9,07:54,false +SKW4460,KMKE,KMSP,CRJ9,07:55,false +SKW3835,KSBN,KMSP,CRJ9,07:55,false +DAL2848,KPIT,KMSP,BCS1,07:56,false +DAL1597,KSTL,KMSP,BCS1,07:56,false +SKW4298,KATY,KMSP,CRJ7,07:57,false +EDV4928,KSDF,KMSP,CRJ7,07:58,false +DAL2491,KCLT,KMSP,BCS1,07:59,false +SKW3702,KFWA,KMSP,CRJ9,07:59,false +SKW4251,KCIU,KMSP,CRJ7,08:01,false +DAL2403,KCMH,KMSP,A320,08:01,false +DAL2303,KBOS,KMSP,A321,08:04,false +EDV5248,KCID,KMSP,CRJ7,08:04,false +DAL1717,KORD,KMSP,BCS1,08:04,false +DAL2221,KBNA,KMSP,B739,08:10,false +EDV5523,KTYS,KMSP,CRJ9,08:10,false +DAL1641,KEWR,KMSP,BCS1,08:13,false +SKW3660,CYWG,KMSP,E75L,08:13,false +SKW3899,KMEM,KMSP,E75L,08:14,false +EDV5384,KCWA,KMSP,CRJ9,08:18,false +DAL2452,KFAR,KMSP,A321,08:19,false +EDV5402,KDSM,KMSP,CRJ7,08:20,false +EDV5344,KRIC,KMSP,CRJ7,08:35,false +SCX250,KBOS,KMSP,B738,08:39,false +UAL2232,KORD,KMSP,B738,08:41,false +EDV5427,CYUL,KMSP,CRJ7,08:57,false +DAL2717,KSAT,KMSP,BCS3,08:59,false +DAL2668,KATL,KMSP,A321,09:00,false +DAL1242,KAUS,KMSP,A320,09:00,false +DAL1129,KDEN,KMSP,B739,09:00,false +DAL2834,KSYR,KMSP,A319,09:07,false +DAL2808,KBOI,KMSP,A320,09:10,false +SKW3922,KDLH,KMSP,CRJ9,09:10,false +DAL1678,KBZN,KMSP,B739,09:11,false +DAL1050,KIAH,KMSP,BCS1,09:13,false +UAL2183,KEWR,KMSP,A319,09:18,false +SKW3542,KICT,KMSP,CRJ9,09:20,false +DAL1138,KBOS,KMSP,A21N,09:22,false +DAL1276,KDFW,KMSP,A320,09:23,false +DAL1493,KDCA,KMSP,BCS3,09:25,false +DAL2390,KJFK,KMSP,BCS1,09:25,false +DAL1056,KMIA,KMSP,B739,09:25,false +SKW4136,KMOT,KMSP,CRJ9,09:25,false +LYM5107,KTVF,KMSP,E145,09:25,false +DAL1250,KMCO,KMSP,B753,09:26,false +SKW4458,KMKE,KMSP,E75L,09:26,false +EDV5464,KORF,KMSP,CRJ7,09:26,false +DAL2254,KBIL,KMSP,A321,09:27,false +DAL2446,KBIS,KMSP,BCS1,09:28,false +DAL2907,KMSO,KMSP,A320,09:28,false +DAL1330,KBDL,KMSP,A321,09:29,false +SKW3927,KRAP,KMSP,CRJ9,09:29,false +DAL1006,KFSD,KMSP,A319,09:30,false +SKW3512,KOMA,KMSP,CRJ9,09:30,false +DAL2219,KRSW,KMSP,A321,09:30,false +AAL2152,KDFW,KMSP,B738,09:41,false +SWA3961,KSTL,KMSP,B737,09:45,false +DAL2473,KLGA,KMSP,B739,09:53,false +DAL2258,KDTW,KMSP,A321,09:54,false +SKW3844,KOKC,KMSP,E75L,09:58,false +ENY4162,KORD,KMSP,E170,10:01,false +DAL1554,KORD,KMSP,BCS3,10:02,false +DAL2263,KTPA,KMSP,B752,10:02,false +AAL2154,KCLT,KMSP,A319,10:04,false +SKW4103,KMSN,KMSP,CRJ9,10:10,false +SWA2160,KBNA,KMSP,B737,10:15,false +DAL2960,KATL,KMSP,A321,10:18,false +DAL2492,CYYC,KMSP,A320,10:18,false +SKW3931,KJAC,KMSP,E75L,10:26,false +DAL1308,KBOS,KMSP,B752,10:27,false +DAL1578,KGPI,KMSP,A321,10:30,false +EDV5325,KSTL,KMSP,CRJ9,10:30,false +RPA3649,KIAD,KMSP,E75L,10:36,false +UAL2805,KORD,KMSP,B737,10:36,false +UAL773,KDEN,KMSP,A320,10:38,false +EDV4867,KMCI,KMSP,CRJ7,10:48,false +UAL2469,KIAH,KMSP,B39M,10:53,false +SWA3420,KDEN,KMSP,B738,10:55,false +SKW4466,KMKE,KMSP,CRJ9,10:57,false +DAL261,BIKF,KMSP,B752,11:02,false +DAL2369,KPDX,KMSP,B739,11:14,false +DAL2875,CYVR,KMSP,A320,11:21,false +DAL161,EHAM,KMSP,A359,11:22,false +DAL1282,KLAS,KMSP,B753,11:22,false +DAL943,KSEA,KMSP,B739,11:24,false +DAL2916,KSLC,KMSP,B739,11:29,false +DAL2090,KATL,KMSP,A321,11:30,false +DAL2178,KSFO,KMSP,A321,11:36,false +EDV5469,KMDW,KMSP,CRJ7,11:37,false +DAL2229,KSMF,KMSP,B739,11:38,false +SWA2869,KMDW,KMSP,B737,11:45,false +DAL2847,KCLE,KMSP,BCS1,11:49,false +DAL2987,KRNO,KMSP,B738,11:49,false +DAL2553,KPHX,KMSP,B739,11:50,false +SKW4273,KBRD,KMSP,CRJ7,11:52,false +DAL1615,KGEG,KMSP,B739,11:52,false +DAL792,KLAX,KMSP,A21N,11:52,false +SKW4305,KRHI,KMSP,CRJ7,11:54,false +DAL2772,KCMH,KMSP,BCS1,11:55,false +DAL954,KCVG,KMSP,A320,11:56,false +DAL2156,KSJC,KMSP,BCS1,11:59,false +DAL2622,KPSC,KMSP,BCS1,12:01,false +SKW3561,KFSD,KMSP,E75L,12:03,false +DAL535,KABQ,KMSP,BCS1,12:04,false +DAL2134,KSAN,KMSP,B739,12:04,false +EDV4998,KDSM,KMSP,CRJ9,12:05,false +SCX1908,KDTW,KMSP,B738,12:05,false +SKW4229,KHIB,KMSP,CRJ7,12:06,false +EDV5077,KFAR,KMSP,CRJ7,12:10,false +DAL2724,KBNA,KMSP,A321,12:14,false +DAL1675,KDEN,KMSP,A21N,12:14,false +SKW3990,KGRR,KMSP,CRJ9,12:14,false +SKW4239,KESC,KMSP,CRJ7,12:15,false +SKW4329,KGFK,KMSP,CRJ7,12:15,false +SKW4288,KIMT,KMSP,CRJ7,12:15,false +SKW4245,KINL,KMSP,CRJ7,12:15,false +SKW3831,KOMA,KMSP,CRJ9,12:15,false +SKW5684,KORD,KMSP,E75L,12:17,false +ENY3796,KDCA,KMSP,E75L,12:20,false +FFT1150,KDEN,KMSP,A21N,12:21,false +KLM655,EHAM,KMSP,B789,12:25,false +EDV4705,KRST,KMSP,CRJ9,12:31,false +DAL845,KPIT,KMSP,BCS1,12:35,false +JIA5658,KPHL,KMSP,CRJ9,12:38,false +SCX652,KDEN,KMSP,B738,12:40,false +UAL397,KDEN,KMSP,A320,12:40,false +SKW3813,KAUS,KMSP,E75L,12:41,false +EDV4951,KATW,KMSP,CRJ7,12:43,false +AAL2153,KCLT,KMSP,A320,12:54,false +SKW3666,KDLH,KMSP,CRJ9,12:54,false +DAL3188,KSTL,KMSP,BCS1,12:55,false +SKW4066,KBIS,KMSP,CRJ9,12:56,false +DAL1827,KCLT,KMSP,BCS1,13:00,false +DAL2456,KIND,KMSP,BCS1,13:04,false +SCX1058,KBUF,KMSP,B738,13:10,false +SWA3973,KDEN,KMSP,B38M,13:10,false +DAL2697,KDTW,KMSP,B739,13:10,false +DAL2409,KLGA,KMSP,A321,13:10,false +DAL2357,KBWI,KMSP,BCS1,13:11,false +DAL153,LFPG,KMSP,A333,13:12,false +LYM5115,KIWD,KMSP,E145,13:15,false +SCX266,KPVD,KMSP,B738,13:18,false +DAL2519,KRDU,KMSP,A21N,13:20,false +DAL2688,KPHL,KMSP,BCS1,13:21,false +EDV4899,KCID,KMSP,CRJ7,13:25,false +WJA1530,CYEG,KMSP,B737,13:25,false +SKW3920,KGRB,KMSP,CRJ9,13:26,false +DAL561,KMKE,KMSP,BCS1,13:26,false +SCX1420,KRIC,KMSP,B738,13:29,false +ENY3375,KORD,KMSP,E170,13:32,false +SKW4102,KMSN,KMSP,CRJ9,13:33,false +DAL2007,KBDL,KMSP,A319,13:34,false +DAL120,RJTT,KMSP,A359,13:35,false +SKW3699,KIAD,KMSP,E75L,13:35,false +DAL2656,KIAH,KMSP,BCS1,13:36,false +SCX234,KEWR,KMSP,B738,13:38,false +SKW3971,KRAP,KMSP,CRJ9,13:38,false +FFT3045,KATL,KMSP,A20N,13:39,false +DAL2185,KSNA,KMSP,BCS3,13:39,false +DAL2958,KDCA,KMSP,BCS3,13:41,false +DAL799,KLAX,KMSP,A21N,13:42,false +SKW3836,CYYZ,KMSP,CRJ9,13:42,false +DAL1730,KMCO,KMSP,B739,13:43,false +DAL2972,KDFW,KMSP,BCS1,13:44,false +DAL2864,KEWR,KMSP,A320,13:45,false +SCX252,KBOS,KMSP,B738,13:47,false +EDV4988,KSDF,KMSP,CRJ7,13:48,false +SKW3536,KXNA,KMSP,E75L,13:48,false +EDV5142,KOKC,KMSP,CRJ7,13:49,false +DAL1766,KPWM,KMSP,B739,13:49,false +DAL2901,KATL,KMSP,A321,13:50,false +DAL2414,KJFK,KMSP,BCS1,13:50,false +SCX490,KGPI,KMSP,B738,13:53,false +AAL1750,KPHX,KMSP,B738,13:54,false +ASA555,KSEA,KMSP,B739,13:57,false +SCX194,KBWI,KMSP,B738,14:00,false +OCN52,EDDF,KMSP,A333,14:00,false +DAL1103,KBOS,KMSP,A21N,14:02,false +SCX568,KJFK,KMSP,B738,14:04,false +WEN3824,CYQR,KMSP,DH8D,14:05,false +WJA1810,CYYC,KMSP,B38M,14:05,false +SCX1776,KPHL,KMSP,B738,14:06,false +SCX342,KMCO,KMSP,B738,14:07,false +DAL2849,KDEN,KMSP,A321,14:08,false +DAL1567,KORD,KMSP,BCS3,14:10,false +DAL2017,KBOI,KMSP,A320,14:11,false +DAL1608,KMCI,KMSP,BCS1,14:12,false +SKW4144,KMOT,KMSP,CRJ9,14:12,false +SKW3995,KTVC,KMSP,CRJ9,14:14,false +SCX214,KTPA,KMSP,B738,14:15,false +DAL447,PANC,KMSP,B752,14:18,false +SKW3871,CYWG,KMSP,E75L,14:18,false +AAL2145,KDFW,KMSP,B738,14:19,false +UAL2749,KORD,KMSP,A319,14:20,false +SKW3989,KMEM,KMSP,E75L,14:22,false +SCX668,KIAD,KMSP,B738,14:23,false +DAL536,KSEA,KMSP,B739,14:24,false +UAL283,KSFO,KMSP,A320,14:24,false +SKW4261,KXWA,KMSP,CRJ7,14:24,false +SKW4089,KCOS,KMSP,CRJ9,14:29,false +SCX422,KLAX,KMSP,B738,14:30,false +DAL259,EIDW,KMSP,A332,14:33,false +SKW3771,KFAR,KMSP,E75L,14:34,false +SCX282,KSEA,KMSP,B738,14:38,false +DAL630,KLAS,KMSP,B739,14:39,false +DAL163,EHAM,KMSP,A333,14:40,false +DAL2924,KSLC,KMSP,B739,14:40,false +DAL1226,KBZN,KMSP,B739,14:44,false +SKW3534,KFSD,KMSP,CRJ9,14:44,false +DAL2181,KAVL,KMSP,BCS1,14:45,false +EDV4931,KBTV,KMSP,CRJ9,14:45,false +DAL2057,KCLE,KMSP,BCS1,14:45,false +DAL1047,KPHX,KMSP,B739,14:45,false +SCX392,KSFO,KMSP,B738,14:47,false +SCX1822,KPWM,KMSP,B738,14:53,false +DAL2301,KTPA,KMSP,A321,14:53,false +EDV4992,KBIS,KMSP,CRJ9,15:00,false +SCX326,KJAX,KMSP,B738,15:00,false +SKW3848,KOMA,KMSP,E75L,15:02,false +DAL2550,KCMH,KMSP,BCS1,15:07,false +DAL2877,KDEN,KMSP,A321,15:10,false +RPA3576,KIAD,KMSP,E75L,15:11,false +DAL9,EGLL,KMSP,A339,15:15,false +JZA719,CYYZ,KMSP,CRJ9,15:15,false +SWA1016,KBWI,KMSP,B38M,15:20,false +RPA4727,KORD,KMSP,E75L,15:29,false +EDV4820,KMDW,KMSP,CRJ7,15:33,false +AAL2157,KPHL,KMSP,A319,15:34,false +SWA364,KAUS,KMSP,B738,15:35,false +SCX384,KRSW,KMSP,B739,15:44,false +SCX104,KLAS,KMSP,B739,15:49,false +UAL1068,KEWR,KMSP,A319,15:55,false +SKW4283,KBJI,KMSP,CRJ7,15:57,false +ASA1429,KPDX,KMSP,B739,15:58,false +EDV5490,KDSM,KMSP,CRJ7,16:00,false +SKW4032,KMSN,KMSP,E75L,16:01,false +DAL117,EKCH,KMSP,A339,16:03,false +DAL2825,KATL,KMSP,B739,16:06,false +FFT3224,KDFW,KMSP,A20N,16:08,false +SKW4262,KABR,KMSP,CRJ7,16:09,false +EDV4774,KFAR,KMSP,CRJ7,16:11,false +UAL2298,KORD,KMSP,A320,16:13,false +EDV5063,KRIC,KMSP,CRJ7,16:19,false +DAL2062,KAUS,KMSP,A320,16:35,false +DAL165,EHAM,KMSP,A339,16:38,false +DAL2819,KCLT,KMSP,BCS1,16:44,false +DAL2521,KRDU,KMSP,BCS3,16:44,false +LYM5112,KTVF,KMSP,E145,16:45,false +UAL732,KDEN,KMSP,A319,16:46,false +DAL2863,KDCA,KMSP,A319,16:49,false +DAL413,KLAX,KMSP,A21N,16:50,false +DAL1224,KPVD,KMSP,A319,16:50,false +SKW3865,KGRR,KMSP,CRJ9,16:51,false +SKW4236,KHIB,KMSP,CRJ7,16:54,false +DAL2272,KLAS,KMSP,B752,16:54,false +DAL2208,KPHX,KMSP,B739,16:55,false +EIN89,EIDW,KMSP,A21N,17:00,false +SWA2832,KMDW,KMSP,B738,17:00,false +DAL2415,KPDX,KMSP,B752,17:01,false +SKW4266,KINL,KMSP,CRJ7,17:03,false +DAL2400,KRSW,KMSP,B739,17:04,false +DAL1127,KBOS,KMSP,A321,17:05,false +SWA1638,KDEN,KMSP,B38M,17:05,false +DAL2349,KLGA,KMSP,A321,17:06,false +AAL2151,KCLT,KMSP,A319,17:07,false +SKW3967,KRAP,KMSP,CRJ9,17:07,false +DAL2895,KEWR,KMSP,BCS1,17:08,false +DAL2463,KCHS,KMSP,A320,17:10,false +DAL2912,KIND,KMSP,B738,17:10,false +DAL2203,KDTW,KMSP,A320,17:12,false +UAL2150,KIAH,KMSP,B738,17:12,false +DAL2462,KVPS,KMSP,B739,17:12,false +JIA5416,KDCA,KMSP,CRJ7,17:15,false +DAL2981,KATL,KMSP,B739,17:16,false +DAL2528,KSAT,KMSP,BCS1,17:16,false +SKW4330,KGFK,KMSP,CRJ7,17:19,false +ASA509,KSEA,KMSP,B738,17:19,false +DAL2006,KMKE,KMSP,BCS1,17:21,false +SKW4284,KXWA,KMSP,CRJ7,17:25,false +DAL2678,KIAH,KMSP,BCS3,17:26,false +EDV4693,KHPN,KMSP,CRJ9,17:27,false +SKW3686,KDLH,KMSP,E75L,17:28,false +DAL1853,KGEG,KMSP,A320,17:29,false +SKW3526,CYWG,KMSP,E75L,17:30,false +DAL2483,CYVR,KMSP,B739,17:31,false +DAL3113,KCVG,KMSP,BCS1,17:32,false +RPA4425,KORD,KMSP,E75L,17:32,false +DAL2936,KSLC,KMSP,A321,17:32,false +SKW3843,KGRB,KMSP,CRJ9,17:34,false +EDV4906,KCID,KMSP,CRJ7,17:35,false +SKW3538,KFSD,KMSP,CRJ9,17:37,false +SKW4295,KSAW,KMSP,CRJ7,17:37,false +DAL2679,KPHL,KMSP,BCS3,17:37,false +DAL941,KSEA,KMSP,B739,17:37,false +SKW4167,KSTL,KMSP,CRJ9,17:37,false +DAL2696,KTPA,KMSP,B739,17:38,false +DAL673,KFLL,KMSP,B753,17:39,false +DAL1208,KMCO,KMSP,B753,17:39,false +SKW3861,KATW,KMSP,CRJ9,17:40,false +EDV4982,KCWA,KMSP,CRJ7,17:40,false +SKW3595,KICT,KMSP,CRJ9,17:40,false +DAL1613,KMCI,KMSP,BCS1,17:40,false +DAL2129,KMIA,KMSP,B739,17:40,false +DAL1533,KORD,KMSP,BCS1,17:40,false +EDV5249,KPIT,KMSP,CRJ9,17:40,false +SKW4473,KRST,KMSP,E75L,17:40,false +DAL2121,KSAN,KMSP,A321,17:40,false +AFR88,LFPG,KMSP,B772,17:50,false +SCX504,KDFW,KMSP,B738,17:53,false +SKW5871,KORD,KMSP,E75L,17:53,false +DAL2900,KDFW,KMSP,A320,18:03,false +DAL914,KLAX,KMSP,B753,18:11,false +DAL2489,CYYC,KMSP,A321,18:12,false +DAL2843,KBNA,KMSP,B739,18:14,false +DAL2654,KATL,KMSP,B752,18:18,false +DAL2078,KSFO,KMSP,A321,18:19,false +SWA1639,KDEN,KMSP,B38M,18:20,false +ICE657,BIKF,KMSP,B38M,18:20,false +DAL170,RKSI,KMSP,A359,18:21,false +AAL2148,KDFW,KMSP,B738,18:23,false +DAL1878,MMUN,KMSP,A321,18:30,false +DAL2061,KSNA,KMSP,BCS1,18:33,false +SWA4643,KMDW,KMSP,B738,18:35,false +DAL2982,KBOI,KMSP,A320,18:37,false +DAL2238,KLAS,KMSP,B752,18:42,false +DAL2383,KSJC,KMSP,A320,18:44,false +UAL2470,KDEN,KMSP,A319,18:46,false +DAL1701,KSMF,KMSP,B739,18:48,false +SKW3851,KRAP,KMSP,CRJ9,18:53,false +SKW4130,KMOT,KMSP,CRJ9,18:57,false +DAL2274,KPHX,KMSP,B752,19:00,false +ASA527,KSEA,KMSP,B739,19:00,false +DAL2167,KPDX,KMSP,A321,19:03,false +SKW3948,KOMA,KMSP,CRJ9,19:04,false +DAL362,KMSO,KMSP,BCS1,19:05,false +DAL1842,KDTW,KMSP,A320,19:07,false +SKW4012,KXNA,KMSP,E75L,19:09,false +EDV5017,KSDF,KMSP,CRJ7,19:12,false +SKW3935,KBIS,KMSP,E75L,19:13,false +DAL2946,KBZN,KMSP,B739,19:13,false +EDV5020,KDSM,KMSP,CRJ9,19:13,false +EDV4915,KTVC,KMSP,CRJ9,19:14,false +SKW4005,CYWG,KMSP,E75L,19:14,false +DAL2271,KDEN,KMSP,A321,19:15,false +DAL2575,KGPI,KMSP,A320,19:15,false +DAL2437,KGEG,KMSP,B739,19:15,false +DAL1630,KMCI,KMSP,BCS1,19:15,false +DAL2695,KMSY,KMSP,A321,19:15,false +DAL2574,KSAV,KMSP,BCS1,19:15,false +DAL3185,KSTL,KMSP,BCS1,19:15,false +DAL605,MMMX,KMSP,A319,19:22,false +DAL2327,KMYR,KMSP,BCS1,19:29,false +SCX634,KBNA,KMSP,B738,19:30,false +EDV5480,KMSN,KMSP,CRJ7,19:30,false +DAL1306,KORD,KMSP,BCS3,19:31,false +SCX1926,KTVC,KMSP,B738,19:39,false +JZA721,CYYZ,KMSP,CRJ9,19:40,false +SCX920,KRDU,KMSP,B738,19:41,false +SCX500,KIND,KMSP,B738,19:47,false +DAL942,KSEA,KMSP,B753,19:54,false +SCX1914,KGRR,KMSP,B738,19:55,false +SCX1880,KRAP,KMSP,B738,19:56,false +DAL1555,KGRR,KMSP,BCS1,19:57,false +DAL2392,KJAX,KMSP,A320,19:59,false +SCX1948,KCLT,KMSP,B738,20:08,false +EDV4803,KTYS,KMSP,CRJ7,20:08,false +EDV4782,KFAR,KMSP,CRJ7,20:09,false +SCX656,KDEN,KMSP,B738,20:17,false +SCX262,KORD,KMSP,B738,20:17,false +DAL2366,CYVR,KMSP,A321,20:18,false +DAL1456,KIND,KMSP,B739,20:20,false +SCX1982,KBDL,KMSP,B738,20:21,false +DAL1098,KBOS,KMSP,A321,20:21,false +DAL1661,KPDX,KMSP,A21N,20:22,false +DAL2113,KCMH,KMSP,BCS1,20:27,false +SKW4462,KMKE,KMSP,E75L,20:29,false +DAL2617,KIAH,KMSP,BCS3,20:32,false +DAL1449,KCLE,KMSP,BCS1,20:37,false +DAL703,KCVG,KMSP,B739,20:41,false +SKW4331,KGFK,KMSP,CRJ7,20:43,false +DAL2015,KLGA,KMSP,B739,20:43,false +DAL2699,KRDU,KMSP,B739,20:48,false +SKW3709,KPIT,KMSP,CRJ9,20:50,false +DAL402,KRSW,KMSP,B739,20:50,false +EDV4713,KMDW,KMSP,CRJ7,20:51,false +DAL1751,KBUF,KMSP,A320,20:52,false +DAL2963,KDCA,KMSP,BCS1,20:52,false +DAL2594,KPHL,KMSP,BCS1,20:53,false +DAL1042,KAUS,KMSP,A21N,20:54,false +SCX220,KCVG,KMSP,B738,20:54,false +DAL2935,KSLC,KMSP,A21N,20:54,false +SKW3829,CYYZ,KMSP,CRJ9,20:54,false +SKW3783,KIAD,KMSP,CRJ9,20:56,false +EDV4933,KJFK,KMSP,CRJ9,20:56,false +DAL2612,KBNA,KMSP,A321,20:58,false +DAL2191,KSAN,KMSP,A321,20:58,false +DAL2675,KSAT,KMSP,BCS1,20:58,false +DAL2198,KLAX,KMSP,A21N,20:59,false +SKW4063,KMEM,KMSP,E75L,20:59,false +DAL555,PANC,KMSP,B752,21:00,false +DAL2285,KBDL,KMSP,BCS1,21:00,false +DAL2565,KBWI,KMSP,BCS3,21:00,false +DAL2820,KCLT,KMSP,A319,21:00,false +DAL2873,KDFW,KMSP,A320,21:00,false +DAL1303,KDTW,KMSP,A321,21:00,false +DAL2866,KEWR,KMSP,BCS1,21:00,false +DAL2225,KMCO,KMSP,B753,21:00,false +DAL2159,KSFO,KMSP,B739,21:00,false +EDV4923,KSDF,KMSP,CRJ7,21:07,false +DAL2264,KTPA,KMSP,A321,21:17,false +AAL2806,KDFW,KMSP,B738,21:21,false +SCX346,KMCO,KMSP,B738,21:23,false +SCX1700,KSLC,KMSP,B738,21:23,false +JZA851,CYUL,KMSP,CRJ9,21:23,false +SCX1492,KCMH,KMSP,B738,21:24,false +SCX606,KPHX,KMSP,B738,21:25,false +SCX1836,KSAT,KMSP,B738,21:25,false +FFT1292,KDEN,KMSP,A20N,21:27,false +DAL1220,KDEN,KMSP,A321,21:34,false +DAL1280,KATL,KMSP,B752,21:38,false +UAL2005,KORD,KMSP,B39M,21:41,false +UAL2076,KDEN,KMSP,B738,21:50,false +JIA5506,KDCA,KMSP,CRJ7,21:54,false +UAL648,KSFO,KMSP,A319,21:57,false +SCX472,PANC,KMSP,B738,21:58,false +JIA5460,KPHL,KMSP,CRJ9,22:01,false +SCX106,KLAS,KMSP,B738,22:03,false +SWA2677,KDEN,KMSP,B737,22:10,false +DAL2130,KORD,KMSP,BCS1,22:12,false +DAL2284,KPHX,KMSP,B739,22:29,false +DAL1542,KLAS,KMSP,B739,22:31,false +SCX408,KSAN,KMSP,B738,23:06,false +UAL1131,KORD,KMSP,B739,23:10,false +SCX296,KPDX,KMSP,B738,23:11,false +SCX426,KLAX,KMSP,B738,23:12,false +SWA4085,KMDW,KMSP,B737,23:20,false +SWA4880,KSTL,KMSP,B738,23:20,false +AAL2176,KCLT,KMSP,A319,23:22,false +SCX286,KSEA,KMSP,B738,23:25,false +UAL2378,KIAH,KMSP,A319,23:29,false +DAL949,KSEA,KMSP,A321,23:30,false +DAL1885,KMCO,KMSP,B752,23:38,false +SCX396,KSFO,KMSP,B738,23:54,false +SWA863,KBWI,KMSP,B38M,23:55,false +DAL2647,KSAN,KMSP,A321,23:56,false +DAL756,KSFO,KMSP,A21N,23:56,false +AAL3149,KDFW,KMSP,B738,23:57,false +AAL3142,KPHX,KMSP,B38M,23:58,false +UAL667,KDEN,KMSP,B738,23:59,false +UAL1331,KEWR,KMSP,B739,23:59,false +ASA386,KSEA,KMSP,B739,23:59,false diff --git a/resources/schedules/README.md b/resources/schedules/README.md new file mode 100644 index 000000000..f88b1e410 --- /dev/null +++ b/resources/schedules/README.md @@ -0,0 +1,8 @@ +# Real World Schedules + +Built-in real-world schedules are stored under this directory, with one +subdirectory per airport. + +See `website/facility-engineering.html` for documentation describing the +schedule manifest format, CSV format, and how real-world schedules integrate +with scenarios. \ No newline at end of file diff --git a/server/manager.go b/server/manager.go index cab0e6590..f7d792b72 100644 --- a/server/manager.go +++ b/server/manager.go @@ -92,6 +92,7 @@ type ScenarioSpec struct { PrimaryAirport string MagneticVariation float32 WindSpecifier *wx.WindSpecifier + RealWorldSchedules []sim.BuiltInScheduleSummary LaunchConfig sim.LaunchConfig diff --git a/server/scenario.go b/server/scenario.go index 568d92b7a..6b6be99e6 100644 --- a/server/scenario.go +++ b/server/scenario.go @@ -1746,6 +1746,16 @@ func initializeSimConfigurations(sg *scenarioGroup, catalogs map[string]map[stri } } +func attachBuiltInSchedules(catalogs map[string]map[string]*ScenarioCatalog, schedules sim.BuiltInScheduleCatalog) { + for _, facilityCatalogs := range catalogs { + for _, catalog := range facilityCatalogs { + for _, scenario := range catalog.Scenarios { + scenario.RealWorldSchedules = schedules.SummariesForAirport(scenario.PrimaryAirport) + } + } + } +} + /////////////////////////////////////////////////////////////////////////// // LoadScenarioGroups @@ -2473,6 +2483,13 @@ func LoadScenarioGroups(extraScenarioFilename string, extraVideoMapFilename stri loadEmergencies(e) + scheduleCatalog, err := sim.LoadBuiltInScheduleCatalog(util.GetResourcesFS(), "schedules") + if err != nil { + e.Error(err) + } else { + attachBuiltInSchedules(catalogs, scheduleCatalog) + } + lg.Infof("LoadScenarioGroups total: %s", time.Since(start)) return scenarioGroups, catalogs, mapSpecs, briefs, extraScenarioErrors } diff --git a/sim/launch_config_test.go b/sim/launch_config_test.go new file mode 100644 index 000000000..0e4bf3e36 --- /dev/null +++ b/sim/launch_config_test.go @@ -0,0 +1,34 @@ +// sim/launch_config_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import "testing" + +func TestMakeLaunchConfigScheduleDefaults(t *testing.T) { + lc := MakeLaunchConfig(nil, 1, 0, nil, nil, false) + + if lc.TrafficSource != TrafficSourceRandom { + t.Fatalf("TrafficSource = %v, want TrafficSourceRandom", lc.TrafficSource) + } + if lc.ScheduleID != "" { + t.Fatalf("ScheduleID = %q, want empty", lc.ScheduleID) + } + if lc.ScheduleStartMinute != 0 { + t.Fatalf("ScheduleStartMinute = %d, want 0", lc.ScheduleStartMinute) + } + if lc.ScheduleArrivalPercentage != 100 { + t.Fatalf( + "ScheduleArrivalPercentage = %d, want 100", + lc.ScheduleArrivalPercentage, + ) + } + + if lc.ScheduleDeparturePercentage != 100 { + t.Fatalf( + "ScheduleDeparturePercentage = %d, want 100", + lc.ScheduleDeparturePercentage, + ) + } +} diff --git a/sim/schedule.go b/sim/schedule.go new file mode 100644 index 000000000..09cd6d020 --- /dev/null +++ b/sim/schedule.go @@ -0,0 +1,248 @@ +// sim/schedule.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" +) + +// ScheduledFlight is one published flight in a real-world schedule. It stores +// only facts from the schedule; runway, route, altitude, and spawn details are +// resolved later by the simulation. +type ScheduledFlight struct { + Callsign string + Origin string + Destination string + AircraftType string + + // ScheduledMinute is expressed as minutes after local midnight at the + // schedule airport. For departures it is the published pushback time; for + // arrivals it is the published arrival/runway operation time. + ScheduledMinute int + + // Cargo flights are retained when the user reduces the traffic percentage. + Cargo bool +} + +// ScheduleOperation describes how a scheduled flight relates to the schedule +// airport. +type ScheduleOperation int + +const ( + ScheduleOperationUnknown ScheduleOperation = iota + ScheduleOperationArrival + ScheduleOperationDeparture +) + +// OperationAt determines whether the flight is an arrival or departure at the +// supplied airport. A flight whose origin and destination both match (or +// neither matches) is not usable for that airport. +func (f ScheduledFlight) OperationAt(airport string) ScheduleOperation { + airport = normalizeScheduleCode(airport) + originMatches := normalizeScheduleCode(f.Origin) == airport + destinationMatches := normalizeScheduleCode(f.Destination) == airport + + switch { + case originMatches && !destinationMatches: + return ScheduleOperationDeparture + case destinationMatches && !originMatches: + return ScheduleOperationArrival + default: + return ScheduleOperationUnknown + } +} + +func normalizeScheduledAircraftType(value string) string { + aircraftType := strings.ToUpper(strings.TrimSpace(value)) + + switch aircraftType { + case "B717": + return "B712" + default: + return aircraftType + } +} + +// LoadScheduleCSV reads a real-world schedule CSV. Required columns may appear +// in any order. Unknown columns are ignored so the format can grow without +// breaking older Vice versions. +func LoadScheduleCSV(r io.Reader) ([]ScheduledFlight, error) { + reader := csv.NewReader(r) + reader.TrimLeadingSpace = true + reader.ReuseRecord = true + + header, err := reader.Read() + if err != nil { + if err == io.EOF { + return nil, fmt.Errorf("schedule CSV is empty") + } + return nil, fmt.Errorf("read schedule CSV header: %w", err) + } + + columns, err := scheduleCSVColumns(header) + if err != nil { + return nil, err + } + + var flights []ScheduledFlight + for line := 2; ; line++ { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read schedule CSV line %d: %w", line, err) + } + if scheduleCSVRecordEmpty(record) { + continue + } + + flight, err := parseScheduledFlight(record, columns) + if err != nil { + return nil, fmt.Errorf("schedule CSV line %d: %w", line, err) + } + flights = append(flights, flight) + } + + if len(flights) == 0 { + return nil, fmt.Errorf("schedule CSV contains no flights") + } + return flights, nil +} + +type scheduleCSVColumn int + +const ( + scheduleCSVCallsign scheduleCSVColumn = iota + scheduleCSVOrigin + scheduleCSVDestination + scheduleCSVAircraftType + scheduleCSVTime + scheduleCSVCargo +) + +var requiredScheduleCSVColumns = map[string]scheduleCSVColumn{ + "callsign": scheduleCSVCallsign, + "origin": scheduleCSVOrigin, + "destination": scheduleCSVDestination, + "aircraft_type": scheduleCSVAircraftType, + "time": scheduleCSVTime, +} + +func scheduleCSVColumns(header []string) (map[scheduleCSVColumn]int, error) { + columns := make(map[scheduleCSVColumn]int) + for i, name := range header { + name = strings.TrimSpace(strings.TrimPrefix(name, "\ufeff")) + name = strings.ToLower(name) + + column, required := requiredScheduleCSVColumns[name] + if name == "cargo" { + column = scheduleCSVCargo + } else if !required { + continue + } + + if _, exists := columns[column]; exists { + return nil, fmt.Errorf("schedule CSV has duplicate %q column", name) + } + columns[column] = i + } + + for name, column := range requiredScheduleCSVColumns { + if _, ok := columns[column]; !ok { + return nil, fmt.Errorf("schedule CSV is missing required %q column", name) + } + } + return columns, nil +} + +func parseScheduledFlight(record []string, columns map[scheduleCSVColumn]int) (ScheduledFlight, error) { + value := func(column scheduleCSVColumn) string { + index, ok := columns[column] + if !ok || index >= len(record) { + return "" + } + return strings.TrimSpace(record[index]) + } + + flight := ScheduledFlight{ + Callsign: strings.ToUpper(value(scheduleCSVCallsign)), + Origin: normalizeScheduleCode(value(scheduleCSVOrigin)), + Destination: normalizeScheduleCode(value(scheduleCSVDestination)), + AircraftType: strings.ToUpper(value(scheduleCSVAircraftType)), + } + + for name, field := range map[string]string{ + "callsign": flight.Callsign, + "origin": flight.Origin, + "destination": flight.Destination, + "aircraft_type": flight.AircraftType, + } { + if field == "" { + return ScheduledFlight{}, fmt.Errorf("%s is empty", name) + } + } + + minute, err := parseScheduleTime(value(scheduleCSVTime)) + if err != nil { + return ScheduledFlight{}, err + } + flight.ScheduledMinute = minute + + if rawCargo := value(scheduleCSVCargo); rawCargo != "" { + cargo, err := parseScheduleBool(rawCargo) + if err != nil { + return ScheduledFlight{}, fmt.Errorf("cargo: %w", err) + } + flight.Cargo = cargo + } + + return flight, nil +} + +func parseScheduleTime(value string) (int, error) { + parts := strings.Split(value, ":") + if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 { + return 0, fmt.Errorf("time %q must use HH:MM in 24-hour local time", value) + } + + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, fmt.Errorf("time %q has an invalid hour", value) + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return 0, fmt.Errorf("time %q has an invalid minute", value) + } + return hour*60 + minute, nil +} + +func parseScheduleBool(value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "yes", "1": + return true, nil + case "false", "no", "0": + return false, nil + default: + return false, fmt.Errorf("%q must be true or false", value) + } +} + +func normalizeScheduleCode(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func scheduleCSVRecordEmpty(record []string) bool { + for _, field := range record { + if strings.TrimSpace(field) != "" { + return false + } + } + return true +} diff --git a/sim/schedule_catalog.go b/sim/schedule_catalog.go new file mode 100644 index 000000000..c281c6ae1 --- /dev/null +++ b/sim/schedule_catalog.go @@ -0,0 +1,201 @@ +// sim/schedule_catalog.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "fmt" + "io/fs" + "path" + "sort" + "strings" +) + +// BuiltInSchedule describes one schedule distributed in Vice's resources. +// Flights are loaded and validated when the catalog is read so malformed +// resource files fail early rather than when a scenario is launched. +type BuiltInSchedule struct { + ID string + Name string + Airport string + Description string + Timezone string + Flights []ScheduledFlight +} + +// BuiltInScheduleCatalog contains all valid schedules found below a resource +// root. Schedules are sorted first by airport and then by display name. +type BuiltInScheduleCatalog struct { + Schedules []BuiltInSchedule +} + +// Find returns a built-in schedule by airport and ID. +func (c BuiltInScheduleCatalog) Find(airport, id string) (BuiltInSchedule, bool) { + airport = normalizeScheduleCode(airport) + id = strings.TrimSpace(id) + for _, schedule := range c.Schedules { + if schedule.Airport == airport && schedule.ID == id { + return schedule, true + } + } + return BuiltInSchedule{}, false +} + +// BuiltInScheduleSummary is the small, client-facing description of a +// built-in schedule. The full flight list stays on the server until a +// scenario is launched. +type BuiltInScheduleSummary struct { + ID string + Name string + Airport string + Description string + Timezone string +} + +// Summary returns the client-facing metadata for a built-in schedule. +func (s BuiltInSchedule) Summary() BuiltInScheduleSummary { + return BuiltInScheduleSummary{ + ID: s.ID, + Name: s.Name, + Airport: s.Airport, + Description: s.Description, + Timezone: s.Timezone, + } +} + +// SummariesForAirport returns client-facing schedule metadata for airport. +func (c BuiltInScheduleCatalog) SummariesForAirport(airport string) []BuiltInScheduleSummary { + schedules := c.ForAirport(airport) + summaries := make([]BuiltInScheduleSummary, len(schedules)) + for i, schedule := range schedules { + summaries[i] = schedule.Summary() + } + return summaries +} + +// ForAirport returns schedules published for airport. The returned slice is a +// copy and may be modified by the caller. +func (c BuiltInScheduleCatalog) ForAirport(airport string) []BuiltInSchedule { + airport = normalizeScheduleCode(airport) + var schedules []BuiltInSchedule + for _, schedule := range c.Schedules { + if schedule.Airport == airport { + schedules = append(schedules, schedule) + } + } + return schedules +} + +// LoadBuiltInScheduleCatalog discovers CSV files in airport directories +// directly below root. For example, schedules/KMSP/Summer Weekday.csv is +// exposed as a KMSP schedule named "Summer Weekday". +func LoadBuiltInScheduleCatalog(filesystem fs.FS, root string) (BuiltInScheduleCatalog, error) { + var catalog BuiltInScheduleCatalog + seen := make(map[string]string) + root = path.Clean(root) + + err := fs.WalkDir(filesystem, root, func(filename string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.EqualFold(path.Ext(entry.Name()), ".csv") { + return nil + } + + // Only load CSV files directly inside an airport directory: + // root/KMSP/Schedule.csv. Nested files are intentionally ignored. + directory := path.Dir(filename) + if path.Dir(directory) != root { + return nil + } + + airport := normalizeScheduleCode(path.Base(directory)) + if airport == "" { + return fmt.Errorf("%s: unable to determine airport from directory", filename) + } + + name := strings.TrimSpace(strings.TrimSuffix(entry.Name(), path.Ext(entry.Name()))) + if name == "" { + return fmt.Errorf("%s: schedule filename must have a name before the .csv extension", filename) + } + + key := airport + "/" + strings.ToLower(name) + if previous, ok := seen[key]; ok { + return fmt.Errorf("duplicate built-in schedule %q in %s and %s", airport+"/"+name, previous, filename) + } + + schedule, err := loadDiscoveredSchedule(filesystem, filename, airport, name) + if err != nil { + return err + } + + seen[key] = filename + catalog.Schedules = append(catalog.Schedules, schedule) + return nil + }) + if err != nil { + return BuiltInScheduleCatalog{}, fmt.Errorf("load built-in schedules: %w", err) + } + + sort.Slice(catalog.Schedules, func(i, j int) bool { + if catalog.Schedules[i].Airport != catalog.Schedules[j].Airport { + return catalog.Schedules[i].Airport < catalog.Schedules[j].Airport + } + return catalog.Schedules[i].Name < catalog.Schedules[j].Name + }) + return catalog, nil +} + +func loadDiscoveredSchedule( + filesystem fs.FS, + filename string, + airport string, + name string, +) (BuiltInSchedule, error) { + csvFile, err := filesystem.Open(filename) + if err != nil { + return BuiltInSchedule{}, fmt.Errorf("open %s: %w", filename, err) + } + + flights, loadErr := LoadScheduleCSV(csvFile) + closeErr := csvFile.Close() + if loadErr != nil { + return BuiltInSchedule{}, fmt.Errorf("%s: %w", filename, loadErr) + } + if closeErr != nil { + return BuiltInSchedule{}, fmt.Errorf("close %s: %w", filename, closeErr) + } + + schedule := BuiltInSchedule{ + ID: name, + Name: scheduleDisplayName(name), + Airport: airport, + Description: "", + Timezone: "", + Flights: flights, + } + if err := validateBuiltInSchedule(schedule); err != nil { + return BuiltInSchedule{}, fmt.Errorf("%s: %w", filename, err) + } + + return schedule, nil +} +func scheduleDisplayName(id string) string { + id = strings.TrimSuffix(id, path.Ext(id)) + + parts := strings.FieldsFunc(id, func(r rune) bool { + return r == '_' || r == '-' + }) + + for i, part := range parts { + if part == strings.ToUpper(part) { + continue + } + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:]) + } + } + + return strings.Join(parts, " ") +} diff --git a/sim/schedule_catalog_test.go b/sim/schedule_catalog_test.go new file mode 100644 index 000000000..6a51c06ae --- /dev/null +++ b/sim/schedule_catalog_test.go @@ -0,0 +1,180 @@ +// sim/schedule_catalog_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "strings" + "testing" + "testing/fstest" +) + +const validScheduleCSV = "callsign,origin,destination,aircraft_type,time,cargo\n" + + "DAL1045,KMSP,KATL,A321,14:05,false\n" + + "FDX1412,KMEM,KMSP,B763,14:17,true\n" + +func TestLoadBuiltInScheduleCatalog(t *testing.T) { + filesystem := fstest.MapFS{ + "schedules/KMSP/summer_weekday.csv": &fstest.MapFile{Data: []byte(validScheduleCSV)}, + "schedules/KMSP/schedules.json": &fstest.MapFile{Data: []byte(`[]`)}, + } + + catalog, err := LoadBuiltInScheduleCatalog(filesystem, "schedules") + if err != nil { + t.Fatalf("LoadBuiltInScheduleCatalog: %v", err) + } + if len(catalog.Schedules) != 1 { + t.Fatalf("got %d schedules, want 1", len(catalog.Schedules)) + } + + schedule := catalog.Schedules[0] + if schedule.Airport != "KMSP" || schedule.ID != "summer_weekday" || schedule.Name != "Summer Weekday" { + t.Fatalf("unexpected schedule metadata: %+v", schedule) + } + if schedule.Description != "" || schedule.Timezone != "" { + t.Fatalf("unexpected optional metadata: %+v", schedule) + } + if len(schedule.Flights) != 2 { + t.Fatalf("got %d flights, want 2", len(schedule.Flights)) + } + if got := catalog.ForAirport("kmsp"); len(got) != 1 { + t.Fatalf("ForAirport returned %d schedules, want 1", len(got)) + } + if got := catalog.ForAirport("KORD"); len(got) != 0 { + t.Fatalf("ForAirport returned %d KORD schedules, want 0", len(got)) + } + + summaries := catalog.SummariesForAirport("KMSP") + if len(summaries) != 1 { + t.Fatalf("SummariesForAirport returned %d schedules, want 1", len(summaries)) + } + if summary := summaries[0]; summary.ID != schedule.ID || + summary.Name != schedule.Name || + summary.Airport != schedule.Airport || + summary.Description != schedule.Description || + summary.Timezone != schedule.Timezone { + t.Fatalf("unexpected schedule summary: %+v", summary) + } +} + +func TestLoadBuiltInScheduleCatalogMultipleAirportsAndSorting(t *testing.T) { + filesystem := fstest.MapFS{ + "schedules/KMSP/summer_weekday.csv": &fstest.MapFile{Data: []byte(validScheduleCSV)}, + "schedules/KMSP/cargo-heavy.csv": &fstest.MapFile{Data: []byte( + "callsign,origin,destination,aircraft_type,time,cargo\n" + + "FDX1412,KMEM,KMSP,B763,14:17,true\n")}, + "schedules/KORD/evening_push.csv": &fstest.MapFile{Data: []byte( + "callsign,origin,destination,aircraft_type,time,cargo\n" + + "UAL123,KORD,KLAX,B738,18:00,false\n")}, + } + + catalog, err := LoadBuiltInScheduleCatalog(filesystem, "schedules") + if err != nil { + t.Fatalf("LoadBuiltInScheduleCatalog: %v", err) + } + if len(catalog.Schedules) != 3 { + t.Fatalf("got %d schedules, want 3", len(catalog.Schedules)) + } + + want := []struct { + airport string + id string + name string + }{ + {"KMSP", "cargo-heavy", "Cargo Heavy"}, + {"KMSP", "summer_weekday", "Summer Weekday"}, + {"KORD", "evening_push", "Evening Push"}, + } + for i, expected := range want { + got := catalog.Schedules[i] + if got.Airport != expected.airport || got.ID != expected.id || got.Name != expected.name { + t.Fatalf("schedule %d = %+v, want airport=%s id=%s name=%s", i, got, expected.airport, expected.id, expected.name) + } + } +} + +func TestLoadBuiltInScheduleCatalogValidation(t *testing.T) { + tests := map[string]fstest.MapFS{ + "flight does not serve airport": { + "schedules/KMSP/weekday.csv": &fstest.MapFile{Data: []byte( + "callsign,origin,destination,aircraft_type,time\n" + + "DAL1,KATL,KDTW,A321,12:00\n")}, + }, + "malformed CSV": { + "schedules/KMSP/weekday.csv": &fstest.MapFile{Data: []byte( + "callsign,origin,destination,aircraft_type,time\n" + + "DAL1,KMSP,KDTW,A321,not-a-time\n")}, + }, + "duplicate schedule name ignoring case": { + "schedules/KMSP/Weekday.csv": &fstest.MapFile{Data: []byte(validScheduleCSV)}, + "schedules/KMSP/weekday.csv": &fstest.MapFile{Data: []byte(validScheduleCSV)}, + }, + } + + for name, filesystem := range tests { + t.Run(name, func(t *testing.T) { + if _, err := LoadBuiltInScheduleCatalog(filesystem, "schedules"); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestLoadBuiltInScheduleCatalogIgnoresNestedCSV(t *testing.T) { + filesystem := fstest.MapFS{ + "schedules/KMSP/other/weekday.csv": &fstest.MapFile{Data: []byte(validScheduleCSV)}, + } + + catalog, err := LoadBuiltInScheduleCatalog(filesystem, "schedules") + if err != nil { + t.Fatalf("LoadBuiltInScheduleCatalog: %v", err) + } + if len(catalog.Schedules) != 0 { + t.Fatalf("got %d schedules, want 0", len(catalog.Schedules)) + } +} + +func TestScheduleDisplayName(t *testing.T) { + tests := map[string]string{ + "summer_weekday": "Summer Weekday", + "cargo-heavy": "Cargo Heavy", + "MSP_AM_Rush": "MSP AM Rush", + "Weekend": "Weekend", + } + for id, want := range tests { + if got := scheduleDisplayName(id); got != want { + t.Errorf("scheduleDisplayName(%q) = %q, want %q", id, got, want) + } + } +} + +func TestBuiltInScheduleCatalogFind(t *testing.T) { + catalog := BuiltInScheduleCatalog{ + Schedules: []BuiltInSchedule{ + { + ID: "summer-weekday", + Airport: "KMSP", + Name: "Summer Weekday", + }, + }, + } + + schedule, ok := catalog.Find("kmsp", "summer-weekday") + if !ok { + t.Fatal("Find did not return the schedule") + } + if schedule.Name != "Summer Weekday" { + t.Fatalf("Find returned %q, want Summer Weekday", schedule.Name) + } + if _, ok := catalog.Find("KMSP", "missing"); ok { + t.Fatal("Find returned a missing schedule") + } +} + +func TestLoadBuiltInScheduleCatalogMissingRoot(t *testing.T) { + _, err := LoadBuiltInScheduleCatalog(fstest.MapFS{}, "schedules") + if err == nil || !strings.Contains(err.Error(), "load built-in schedules") { + t.Fatalf("got error %v, want wrapped missing-root error", err) + } +} diff --git a/sim/schedule_test.go b/sim/schedule_test.go new file mode 100644 index 000000000..2e37491c4 --- /dev/null +++ b/sim/schedule_test.go @@ -0,0 +1,106 @@ +// sim/schedule_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "strings" + "testing" +) + +func TestLoadScheduleCSV(t *testing.T) { + input := `destination,time,callsign,cargo,aircraft_type,origin,ignored +KATL,14:05,dal1045,false,A321,KMSP,value +KMSP,14:17,FDX1412,yes,B763,KMEM,value +` + + flights, err := LoadScheduleCSV(strings.NewReader(input)) + if err != nil { + t.Fatalf("LoadScheduleCSV: %v", err) + } + if len(flights) != 2 { + t.Fatalf("got %d flights, want 2", len(flights)) + } + + departure := flights[0] + if departure.Callsign != "DAL1045" || departure.Origin != "KMSP" || departure.Destination != "KATL" || + departure.AircraftType != "A321" || departure.ScheduledMinute != 14*60+5 || departure.Cargo { + t.Errorf("unexpected departure: %#v", departure) + } + if got := departure.OperationAt("kmsp"); got != ScheduleOperationDeparture { + t.Errorf("departure OperationAt = %v, want %v", got, ScheduleOperationDeparture) + } + + arrival := flights[1] + if !arrival.Cargo { + t.Errorf("cargo = false, want true") + } + if got := arrival.OperationAt("KMSP"); got != ScheduleOperationArrival { + t.Errorf("arrival OperationAt = %v, want %v", got, ScheduleOperationArrival) + } +} + +func TestLoadScheduleCSVValidation(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "empty", + input: "", + want: "empty", + }, + { + name: "missing column", + input: "callsign,origin,destination,time\nDAL1,KMSP,KATL,14:05\n", + want: `missing required "aircraft_type"`, + }, + { + name: "bad time", + input: "callsign,origin,destination,aircraft_type,time\nDAL1,KMSP,KATL,A321,25:05\n", + want: "invalid hour", + }, + { + name: "bad cargo", + input: "callsign,origin,destination,aircraft_type,time,cargo\nDAL1,KMSP,KATL,A321,14:05,maybe\n", + want: "must be true or false", + }, + { + name: "no flights", + input: "callsign,origin,destination,aircraft_type,time\n", + want: "contains no flights", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := LoadScheduleCSV(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestScheduledFlightOperationAt(t *testing.T) { + tests := []struct { + name string + flight ScheduledFlight + want ScheduleOperation + }{ + {"departure", ScheduledFlight{Origin: "KMSP", Destination: "KATL"}, ScheduleOperationDeparture}, + {"arrival", ScheduledFlight{Origin: "KATL", Destination: "KMSP"}, ScheduleOperationArrival}, + {"unrelated", ScheduledFlight{Origin: "KATL", Destination: "KDTW"}, ScheduleOperationUnknown}, + {"same airport", ScheduledFlight{Origin: "KMSP", Destination: "KMSP"}, ScheduleOperationUnknown}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.flight.OperationAt("KMSP"); got != test.want { + t.Errorf("OperationAt = %v, want %v", got, test.want) + } + }) + } +} diff --git a/sim/schedule_validate.go b/sim/schedule_validate.go new file mode 100644 index 000000000..299b458dd --- /dev/null +++ b/sim/schedule_validate.go @@ -0,0 +1,153 @@ +// sim/schedule_validate.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "fmt" + + av "github.com/mmp/vice/aviation" +) + +const ( + scheduledDepartureActiveMinutes = 45 + scheduledArrivalActiveMinutes = 45 + minutesPerScheduleDay = 24 * 60 +) + +type scheduledCallsignUse struct { + flight ScheduledFlight + row int + start int + end int +} + +func scheduledFlightActiveWindow( + flight ScheduledFlight, + airport string, +) (start int, end int) { + switch flight.OperationAt(airport) { + case ScheduleOperationDeparture: + return flight.ScheduledMinute, + flight.ScheduledMinute + scheduledDepartureActiveMinutes + + case ScheduleOperationArrival: + return flight.ScheduledMinute - scheduledArrivalActiveMinutes, + flight.ScheduledMinute + + default: + return flight.ScheduledMinute, flight.ScheduledMinute + } +} +func scheduleWindowsOverlap( + firstStart int, + firstEnd int, + secondStart int, + secondEnd int, +) bool { + return firstStart < secondEnd && secondStart < firstEnd +} + +// validateBuiltInSchedule checks schedule-wide rules that cannot be validated +// while parsing an individual CSV row. +func validateBuiltInSchedule(schedule BuiltInSchedule) error { + seenRows := make(map[ScheduledFlight]int) + callsignUses := make(map[string][]scheduledCallsignUse) + + for index, flight := range schedule.Flights { + row := index + 2 + + if flight.OperationAt(schedule.Airport) == ScheduleOperationUnknown { + return fmt.Errorf( + "row %d callsign %s is neither an arrival nor departure at %s", + row, + flight.Callsign, + schedule.Airport, + ) + } + + aircraftType := normalizeScheduledAircraftType(flight.AircraftType) + if _, ok := av.DB.AircraftPerformance[aircraftType]; !ok { + return fmt.Errorf( + "row %d callsign %s uses unknown aircraft type %s", + row, + flight.Callsign, + aircraftType, + ) + } + if _, ok := av.DB.Airports[flight.Origin]; !ok { + return fmt.Errorf( + "row %d callsign %s uses unknown origin airport %s", + row, + flight.Callsign, + flight.Origin, + ) + } + + if _, ok := av.DB.Airports[flight.Destination]; !ok { + return fmt.Errorf( + "row %d callsign %s uses unknown destination airport %s", + row, + flight.Callsign, + flight.Destination, + ) + } + + if previousRow, ok := seenRows[flight]; ok { + return fmt.Errorf( + "row %d exactly duplicates row %d for callsign %s", + row, + previousRow, + flight.Callsign, + ) + } + seenRows[flight] = row + start, end := scheduledFlightActiveWindow(flight, schedule.Airport) + callsignUses[flight.Callsign] = append( + callsignUses[flight.Callsign], + scheduledCallsignUse{ + flight: flight, + row: row, + start: start, + end: end, + }, + ) + } + for callsign, uses := range callsignUses { + for firstIndex := 0; firstIndex < len(uses); firstIndex++ { + first := uses[firstIndex] + + for secondIndex := firstIndex + 1; secondIndex < len(uses); secondIndex++ { + second := uses[secondIndex] + + overlaps := false + for _, dayOffset := range []int{ + -minutesPerScheduleDay, + 0, + minutesPerScheduleDay, + } { + if scheduleWindowsOverlap( + first.start, + first.end, + second.start+dayOffset, + second.end+dayOffset, + ) { + overlaps = true + break + } + } + + if overlaps { + return fmt.Errorf( + "callsign %s has overlapping active windows on rows %d and %d", + callsign, + first.row, + second.row, + ) + } + } + } + } + return nil +} diff --git a/sim/schedule_validate_test.go b/sim/schedule_validate_test.go new file mode 100644 index 000000000..e2799efb7 --- /dev/null +++ b/sim/schedule_validate_test.go @@ -0,0 +1,156 @@ +package sim + +import "testing" + +func TestValidateBuiltInSchedule(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL100", + Origin: "KMSP", + Destination: "KORD", + AircraftType: "A320", + ScheduledMinute: 600, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestValidateBuiltInScheduleRejectsDuplicateRows(t *testing.T) { + flight := ScheduledFlight{ + Callsign: "DAL100", + Origin: "KMSP", + Destination: "KORD", + AircraftType: "A320", + ScheduledMinute: 600, + } + + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + flight, + flight, + }, + } + + if err := validateBuiltInSchedule(schedule); err == nil { + t.Fatal("expected duplicate row validation error") + } +} +func TestValidateBuiltInScheduleAllowsCallsignReuse(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL100", + Origin: "KMSP", + Destination: "KORD", + AircraftType: "A320", + ScheduledMinute: 600, + }, + { + Callsign: "DAL100", + Origin: "KORD", + Destination: "KMSP", + AircraftType: "A320", + ScheduledMinute: 780, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} +func TestValidateBuiltInScheduleRejectsOverlappingCallsignReuse(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL100", + Origin: "KMSP", + Destination: "KORD", + AircraftType: "A320", + ScheduledMinute: 9 * 60, + }, + { + Callsign: "DAL100", + Origin: "KORD", + Destination: "KMSP", + AircraftType: "A320", + ScheduledMinute: 9*60 + 20, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err == nil { + t.Fatal("expected overlapping callsign validation error") + } +} +func TestValidateBuiltInScheduleRejectsCrossMidnightCallsignOverlap(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL200", + Origin: "KMSP", + Destination: "KORD", + AircraftType: "A320", + ScheduledMinute: 23*60 + 55, + }, + { + Callsign: "DAL200", + Origin: "KORD", + Destination: "KMSP", + AircraftType: "A320", + ScheduledMinute: 10, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err == nil { + t.Fatal("expected cross-midnight callsign validation error") + } +} +func TestValidateBuiltInScheduleRejectsUnknownOriginAirport(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL300", + Origin: "KZZZ", + Destination: "KMSP", + AircraftType: "A320", + ScheduledMinute: 12 * 60, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err == nil { + t.Fatal("expected unknown origin airport validation error") + } +} + +func TestValidateBuiltInScheduleRejectsUnknownDestinationAirport(t *testing.T) { + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + { + Callsign: "DAL301", + Origin: "KMSP", + Destination: "KZZZ", + AircraftType: "A320", + ScheduledMinute: 13 * 60, + }, + }, + } + + if err := validateBuiltInSchedule(schedule); err == nil { + t.Fatal("expected unknown destination airport validation error") + } +} diff --git a/sim/sim.go b/sim/sim.go index 2f8e413e4..eed08a2b4 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -110,6 +110,13 @@ type Sim struct { Rand *rand.Rand + // User-selected scenario start time before Vice rewinds the clock for prespawn. + scheduleStart Time + + // Runtime-only source for automatically generated IFR traffic. This is + // intentionally unexported so it is not part of saved simulation state. + trafficProvider trafficProvider + VoiceAssigner *VoiceAssigner SquawkWarnedACIDs map[ACID]any // Warn once in CheckLeaks(); don't spam the logs @@ -249,6 +256,8 @@ func NewSim(config NewSimConfiguration, lg *log.Logger) *Sim { Rand: rand.Make(), + scheduleStart: NewSimTime(config.StartTime.UTC()), + SquawkWarnedACIDs: make(map[ACID]any), wxProvider: config.WXProvider, diff --git a/sim/spawn.go b/sim/spawn.go index e30c08223..deb97e399 100644 --- a/sim/spawn.go +++ b/sim/spawn.go @@ -84,6 +84,14 @@ const ( LaunchManual ) +// TrafficSource identifies how automatic IFR traffic is selected. +type TrafficSource int32 + +const ( + TrafficSourceRandom TrafficSource = iota + TrafficSourceRealWorldSchedule +) + // LaunchConfig collects settings related to launching aircraft in the sim; it's // passed back and forth between client and server: server provides them so client // can draw the UI for what's available, then client returns one back when launching. @@ -96,6 +104,21 @@ type LaunchConfig struct { ArrivalMode int32 OverflightMode int32 + // TrafficSource controls whether automatic IFR aircraft come from the + // scenario's existing random traffic generator or a built-in schedule. + TrafficSource TrafficSource + // ScheduleID identifies the selected built-in schedule when TrafficSource + // is TrafficSourceRealWorldSchedule. + ScheduleID string + // ScheduleStartMinute is the selected local start time, expressed as + // minutes after midnight at the schedule airport. + ScheduleStartMinute int + // ScheduleArrivalPercentage is the percentage of scheduled IFR arrivals to use. + ScheduleArrivalPercentage int + + // ScheduleDeparturePercentage is the percentage of scheduled IFR departures to use. + ScheduleDeparturePercentage int + GoAroundRate float32 EnableTowerGoArounds bool // airport -> runway -> category -> rate @@ -120,6 +143,9 @@ type LaunchConfig struct { func MakeLaunchConfig(dep []DepartureRunway, vfrRateScale float32, vffRequestRate int32, vfrAirports map[string]*av.Airport, inbound map[string]map[string]float32, haveVFRReportingRegions bool) LaunchConfig { lc := LaunchConfig{ + TrafficSource: TrafficSourceRandom, + ScheduleArrivalPercentage: 100, + ScheduleDeparturePercentage: 100, GoAroundRate: 0.01, DepartureRateScale: 1, VFRDepartureRateScale: vfrRateScale, @@ -316,7 +342,19 @@ func (s *Sim) SetLaunchConfig(tcw TCW, lc LaunchConfig) error { s.lg.Info("Set launch config", slog.Any("launch_config", lc)) + providerChanged := lc.TrafficSource != s.State.LaunchConfig.TrafficSource || + lc.ScheduleID != s.State.LaunchConfig.ScheduleID || + lc.ScheduleStartMinute != s.State.LaunchConfig.ScheduleStartMinute + s.State.LaunchConfig = lc + if providerChanged { + s.trafficProvider = nil + for _, runways := range s.DepartureState { + for _, depState := range runways { + depState.NextIFRSpawn = s.State.SimTime + } + } + } s.publish() return nil } @@ -361,7 +399,8 @@ func (s *Sim) LaunchAircraft(ac Aircraft, departureRunway av.RunwayID) { } func (s *Sim) addDepartureToPool(ac *Aircraft, runway av.RunwayID, manualLaunch bool) { - depac := makeDepartureAircraft(ac, s.State.SimTime, s.wxModel, s.Rand) + depac := makeDepartureAircraft(ac, s.State.SimTime, s.wxModel, + s.State.LaunchConfig.TrafficSource, s.Rand) ac.WaitingForLaunch = true s.addAircraftNoLock(*ac) @@ -370,7 +409,6 @@ func (s *Sim) addDepartureToPool(ac *Aircraft, runway av.RunwayID, manualLaunch depState := s.DepartureState[ac.FlightPlan.DepartureAirport][runway] if ac.FlightPlan.Rules == av.FlightRulesIFR { if manualLaunch { - // Keep them moving and for HFR, request the release immediately. depac.ReadyDepartGateTime = depac.SpawnTime } // IFRs spend some time at the gate to give them a chance to appear @@ -502,7 +540,15 @@ func (s *Sim) setInitialSpawnTimes(now Time) { rate = scaleRate(rate, s.State.LaunchConfig.InboundFlowRateScale) rateSum += rate } - s.NextInboundSpawn[group] = randomDelay(rateSum) + + nextInboundSpawn := randomDelay(rateSum) + if s.State.LaunchConfig.TrafficSource == TrafficSourceRealWorldSchedule { + // The schedule provider owns arrival timing, so ask it immediately + // for the next published runway-arrival event. + nextInboundSpawn = now + } + + s.NextInboundSpawn[group] = nextInboundSpawn } for name := range s.State.DepartureAirports { @@ -511,9 +557,15 @@ func (s *Sim) setInitialSpawnTimes(now Time) { if runwayRates, ok := s.State.LaunchConfig.DepartureRates[name]; ok { for rwy, rate := range runwayRates { r := sumRateMap(rate, s.State.LaunchConfig.DepartureRateScale) + nextIFRSpawn := randomDelay(r) + if s.State.LaunchConfig.TrafficSource == TrafficSourceRealWorldSchedule { + // The schedule provider owns the timing. Ask it immediately + // for the next published runway departure. + nextIFRSpawn = now + } s.DepartureState[name][rwy] = &RunwayLaunchState{ IFRSpawnRate: r, - NextIFRSpawn: randomDelay(r), + NextIFRSpawn: nextIFRSpawn, } } } diff --git a/sim/spawn_arrivals.go b/sim/spawn_arrivals.go index 67f888f0e..99cdb6123 100644 --- a/sim/spawn_arrivals.go +++ b/sim/spawn_arrivals.go @@ -5,6 +5,7 @@ package sim import ( + "errors" "fmt" "log/slog" "maps" @@ -15,10 +16,28 @@ import ( av "github.com/mmp/vice/aviation" "github.com/mmp/vice/log" + "github.com/mmp/vice/math" "github.com/mmp/vice/rand" "github.com/mmp/vice/util" ) +const scheduledArrivalMinSpawnSeparationNM = 10 + +var errScheduledArrivalSpawnConflict = errors.New("scheduled arrival spawn point occupied") + +func (s *Sim) scheduledArrivalSpawnConflict(candidate *Aircraft) bool { + for _, existing := range s.Aircraft { + if !existing.IsArrival() { + continue + } + if math.NMDistance2LL(candidate.Position(), existing.Position()) < + scheduledArrivalMinSpawnSeparationNM { + return true + } + } + return false +} + func (s *Sim) spawnArrivalsAndOverflights() { now := s.State.SimTime @@ -58,24 +77,19 @@ func (s *Sim) spawnArrivalsAndOverflights() { continue // Nothing automatic in this group } - flow, rateSum := sampleRateMap(filteredRates, s.State.LaunchConfig.InboundFlowRateScale, s.Rand) - - var ac *Aircraft - var err error - if flow == "overflights" { - ac, err = s.createOverflightNoLock(group) - } else { - ac, err = s.createArrivalNoLock(group, flow) - } + ac, delay, err := s.activeTrafficProvider().createInbound(s, group, filteredRates, pushActive) if err != nil { s.lg.Errorf("create inbound error: %v", err) - } else if ac != nil { + } + if ac != nil && err == nil { s.addAircraftNoLock(*ac) - s.NextInboundSpawn[group] = now.Add(randomWait(rateSum, pushActive, s.Rand)) } + s.NextInboundSpawn[group] = now.Add(max(time.Millisecond, delay)) + } } + } func (s *Sim) CreateArrival(arrivalGroup string, arrivalAirport string) (*Aircraft, error) { @@ -119,15 +133,25 @@ func (s *Sim) createArrivalNoLock(group string, arrivalAirport string) (*Aircraf return nil, err } - err = ac.InitializeArrival(s.State.Airports[arrivalAirport], &arr, - s.State.NmPerLongitude, s.State.MagneticVariation, s.wxModel, s.State.SimTime, s.lg) + return s.initializeArrivalNoLock(ac, &arr, group, arrivalAirport) +} +func (s *Sim) initializeArrivalNoLock(ac *Aircraft, arr *av.Arrival, group string, + arrivalAirport string) (*Aircraft, error) { + err := ac.InitializeArrival(s.State.Airports[arrivalAirport], arr, + s.State.NmPerLongitude, s.State.MagneticVariation, + s.wxModel, s.State.SimTime, s.lg) if err != nil { return nil, err } + return s.finalizeArrivalNoLock(ac, arr, group, arrivalAirport) +} + +func (s *Sim) finalizeArrivalNoLock(ac *Aircraft, arr *av.Arrival, group string, + arrivalAirport string) (*Aircraft, error) { nasFp := s.initNASFlightPlan(ac, av.FlightTypeArrival) nasFp.Route = ac.FlightPlan.Route - nasFp.EntryFix = "" // TODO + nasFp.EntryFix = "" if len(ac.FlightPlan.ArrivalAirport) == 4 { nasFp.ExitFix = ac.FlightPlan.ArrivalAirport[1:] } else { @@ -140,7 +164,6 @@ func (s *Sim) createArrivalNoLock(group string, arrivalAirport string) (*Aircraf nasFp.Scratchpad = arr.Scratchpad nasFp.SecondaryScratchpad = arr.SecondaryScratchpad nasFp.RNAV = s.State.FacilityAdaptation.Datablocks.DisplayRNAVSymbol && arr.IsRNAV - // For ERAM, set AssignedAltitude and derive PerceivedAssigned from waypoint restrictions. if _, isERAM := av.DB.ARTCCs[s.State.Facility]; isERAM { spawnAlt := ac.Nav.FlightState.Altitude @@ -173,15 +196,101 @@ func (s *Sim) createArrivalNoLock(group string, arrivalAirport string) (*Aircraf if err := s.assignSquawk(ac, &nasFp); err != nil { return nil, err } - // Create a flight strip at the inbound handoff controller if it's a human position - if shouldCreateFlightStrip(&nasFp) && !s.isVirtualController(nasFp.InboundHandoffController) { + if shouldCreateFlightStrip(&nasFp) && + !s.isVirtualController(nasFp.InboundHandoffController) { s.initFlightStrip(&nasFp, nasFp.InboundHandoffController) } return ac, s.associateAtSpawn(ac, nasFp) } +func resolveScheduledArrival(arrivals []av.Arrival, arrivalAirport, + origin string) (*av.Arrival, error) { + arrivalAirport = normalizeScheduleCode(arrivalAirport) + origin = normalizeScheduleCode(origin) + + for i := range arrivals { + arr := &arrivals[i] + for _, airline := range arr.Airlines[arrivalAirport] { + if normalizeScheduleCode(airline.Airport) == origin { + return arr, nil + } + } + } + + return nil, fmt.Errorf("no arrival route from %s to %s", origin, arrivalAirport) +} + +// createScheduledArrivalNoLock creates an arrival using the published +// callsign, aircraft type, origin, and destination. Vice continues to resolve +// the STAR, initial controller, altitude, and spawn geometry from the scenario. +func (s *Sim) createScheduledArrivalNoLock(flight ScheduledFlight, group, + arrivalAirport string) (*Aircraft, error) { + if flight.OperationAt(arrivalAirport) != ScheduleOperationArrival { + return nil, fmt.Errorf("%s is not an arrival at %s", + flight.Callsign, arrivalAirport) + } + + inboundFlow, ok := s.State.InboundFlows[group] + if !ok { + return nil, fmt.Errorf("unknown inbound flow %s", group) + } + + arr, err := resolveScheduledArrival( + inboundFlow.Arrivals, + arrivalAirport, + flight.Origin, + ) + if err != nil { + return nil, fmt.Errorf("%s: %w", flight.Callsign, err) + } + + callsign := strings.ToUpper(strings.TrimSpace(flight.Callsign)) + if callsign == "" { + return nil, fmt.Errorf("scheduled arrival callsign is empty") + } + if av.CallsignClashesWithExisting( + s.currentCallsigns(), + callsign, + s.EnforceUniqueCallsignSuffix, + ) { + return nil, fmt.Errorf( + "scheduled arrival callsign %s is already in use", + callsign, + ) + } + + aircraftType := normalizeScheduledAircraftType(flight.AircraftType) + if _, ok := av.DB.AircraftPerformance[aircraftType]; !ok { + return nil, fmt.Errorf( + "aircraft type %s is not present in the performance database", + aircraftType, + ) + } + + ac := &Aircraft{ + ADSBCallsign: av.ADSBCallsign(callsign), + Mode: av.TransponderModeAltitude, + } + ac.InitializeFlightPlan( + av.FlightRulesIFR, + aircraftType, + normalizeScheduleCode(flight.Origin), + normalizeScheduleCode(flight.Destination), + ) + + if err := ac.InitializeArrival(s.State.Airports[arrivalAirport], arr, + s.State.NmPerLongitude, s.State.MagneticVariation, + s.wxModel, s.State.SimTime, s.lg); err != nil { + return nil, err + } + if s.scheduledArrivalSpawnConflict(ac) { + return nil, errScheduledArrivalSpawnConflict + } + + return s.finalizeArrivalNoLock(ac, arr, group, arrivalAirport) +} func (s *Sim) currentCallsigns() []av.ADSBCallsign { callsigns := slices.Collect(maps.Keys(s.Aircraft)) for _, fp := range s.STARSComputer.FlightPlans { diff --git a/sim/spawn_arrivals_test.go b/sim/spawn_arrivals_test.go new file mode 100644 index 000000000..bee70eef6 --- /dev/null +++ b/sim/spawn_arrivals_test.go @@ -0,0 +1,63 @@ +package sim + +import ( + "testing" + + av "github.com/mmp/vice/aviation" +) + +func TestResolveScheduledArrival(t *testing.T) { + arrivals := []av.Arrival{ + { + Airlines: map[string][]av.ArrivalAirline{ + "KMSP": { + { + AirlineSpecifier: av.AirlineSpecifier{ + ICAO: "DAL", + }, + Airport: "KORD", + }, + }, + }, + }, + { + Airlines: map[string][]av.ArrivalAirline{ + "KMSP": { + { + AirlineSpecifier: av.AirlineSpecifier{ + ICAO: "UAL", + }, + Airport: "KDEN", + }, + }, + }, + }, + } + + arrival, err := resolveScheduledArrival(arrivals, "KMSP", "KDEN") + if err != nil { + t.Fatalf("resolveScheduledArrival returned an error: %v", err) + } + if arrival != &arrivals[1] { + t.Fatal("resolveScheduledArrival returned the wrong arrival route") + } +} + +func TestResolveScheduledArrivalRejectsUnknownOrigin(t *testing.T) { + arrivals := []av.Arrival{ + { + Airlines: map[string][]av.ArrivalAirline{ + "KMSP": { + { + Airport: "KORD", + }, + }, + }, + }, + } + + _, err := resolveScheduledArrival(arrivals, "KMSP", "KLAX") + if err == nil { + t.Fatal("resolveScheduledArrival accepted an unknown origin") + } +} diff --git a/sim/spawn_departures.go b/sim/spawn_departures.go index cb3e1d3df..87a8c1bbc 100644 --- a/sim/spawn_departures.go +++ b/sim/spawn_departures.go @@ -79,10 +79,14 @@ func (s *Sim) spawnDepartures() { // Possibly spawn another aircraft, depending on how much time has // passed since the last one. if now.After(depState.NextIFRSpawn) { - if ac, err := s.makeNewIFRDeparture(airport, runway); ac != nil && err == nil { + ac, delay, err := s.activeTrafficProvider().createIFRDeparture(s, airport, runway) + if err != nil { + s.lg.Warnf("unable to create IFR departure: %v", err) + } + if ac != nil && err == nil { s.addDepartureToPool(ac, runway, false /* not manual launch */) - depState.NextIFRSpawn = now.Add(randomWait(depState.IFRSpawnRate, false, s.Rand)) } + depState.NextIFRSpawn = now.Add(max(time.Millisecond, delay)) } if now.After(depState.NextVFRSpawn) { if ac, err := s.makeNewVFRDeparture(airport, runway); ac != nil && err == nil { @@ -652,32 +656,18 @@ func (s *Sim) CreateIFRDeparture(departureAirport string, runway av.RunwayID, ca // aircraft/airline, initializes the flight plan and navigation, builds the NAS flight // plan, assigns controller (handling virtual vs human controllers), and registers with STARS. func (s *Sim) createIFRDepartureNoLock(departureAirport string, runway av.RunwayID, category string) (*Aircraft, error) { - // Validate airport exists - ap := s.State.Airports[departureAirport] - if ap == nil { - return nil, av.ErrUnknownAirport - } - - // Find the runway configuration - idx := slices.IndexFunc(s.State.DepartureRunways, - func(r DepartureRunway) bool { - return r.Airport == departureAirport && r.Runway == runway && r.Category == category - }) - if idx == -1 { - return nil, av.ErrUnknownRunway + ap, rwy, exitRoutes, err := s.departureConfiguration(departureAirport, runway, category) + if err != nil { + return nil, err } - rwy := &s.State.DepartureRunways[idx] - exitRoutes := ap.DepartureRoutes[rwy.Runway] - - // Sample uniformly, minding the category, if specified - idx = rand.SampleFiltered(s.Rand, ap.Departures, + // Sample uniformly, minding the category, if specified. + idx := rand.SampleFiltered(s.Rand, ap.Departures, func(d av.Departure) bool { - _, ok := exitRoutes[d.Exit] // make sure the runway handles the exit + _, ok := exitRoutes[d.Exit] return ok && (rwy.Category == "" || rwy.Category == ap.ExitCategories[d.Exit]) }) if idx == -1 { - // This shouldn't ever happen... return nil, fmt.Errorf("%s/%s: unable to find a valid departure", departureAirport, rwy.Runway) } dep := &ap.Departures[idx] @@ -694,12 +684,173 @@ func (s *Sim) createIFRDepartureNoLock(departureAirport string, runway av.Runway return nil, err } + return s.initializeIFRDepartureNoLock(ac, ap, departureAirport, runway, dep, exitRoutes) +} + +// createScheduledIFRDepartureNoLock creates a departure at the scheduled time +// using the published callsign and aircraft type. Vice's normal scenario logic +// selects the runway-compatible departure, destination, exit, SID, route, +// altitude, and controller assignment. +// createScheduledIFRDepartureNoLock creates a published scheduled departure +// using the matching departure definition from the active Vice scenario. +// +// The schedule supplies the callsign, aircraft type, origin, destination, and +// time. The scenario supplies the compatible runway, exit, SID, route, +// altitude, and controller assignment. +func (s *Sim) createScheduledIFRDepartureNoLock( + flight ScheduledFlight, + departureAirport string, + runway av.RunwayID, + category string, +) (*Aircraft, error) { + if flight.OperationAt(departureAirport) != ScheduleOperationDeparture { + return nil, fmt.Errorf( + "%s is not a departure from %s", + flight.Callsign, + departureAirport, + ) + } + + ap, rwy, exitRoutes, err := s.departureConfiguration( + departureAirport, + runway, + category, + ) + if err != nil { + return nil, err + } + + destination := normalizeScheduleCode(flight.Destination) + + destinationModeled := false + + for i := range ap.Departures { + if normalizeScheduleCode(ap.Departures[i].Destination) == destination { + destinationModeled = true + break + } + } + + if !destinationModeled { + return nil, fmt.Errorf( + "%s: destination %s is not modeled in the %s scenario departures", + flight.Callsign, + destination, + departureAirport, + ) + } + + // Find exact scenario departure definitions for the scheduled destination + // that are compatible with this runway and departure category. + var candidates []*av.Departure + + for i := range ap.Departures { + dep := &ap.Departures[i] + + if normalizeScheduleCode(dep.Destination) != destination { + continue + } + + if _, ok := exitRoutes[dep.Exit]; !ok { + continue + } + + if rwy.Category != "" && + rwy.Category != ap.ExitCategories[dep.Exit] { + continue + } + + candidates = append(candidates, dep) + } + + // This runway cannot handle the scheduled destination. Return the sentinel + // error so the provider leaves the flight at the front of the queue and + // allows another active runway to claim it. + if len(candidates) == 0 { + return nil, errScheduledDepartureRunwayMismatch + } + + // Some scenarios may contain more than one valid departure definition for + // the same destination and runway. Let Vice randomly choose among those + // equally valid scenario definitions. + dep := candidates[s.Rand.Intn(len(candidates))] + + callsign := strings.ToUpper(strings.TrimSpace(flight.Callsign)) + if callsign == "" { + return nil, fmt.Errorf("scheduled departure callsign is empty") + } + + if av.CallsignClashesWithExisting( + s.currentCallsigns(), + callsign, + s.EnforceUniqueCallsignSuffix, + ) { + return nil, fmt.Errorf( + "scheduled departure callsign %s is already in use", + callsign, + ) + } + + aircraftType := normalizeScheduledAircraftType(flight.AircraftType) + if _, ok := av.DB.AircraftPerformance[aircraftType]; !ok { + return nil, fmt.Errorf( + "aircraft type %s is not present in the performance database", + aircraftType, + ) + } + + ac := &Aircraft{ + ADSBCallsign: av.ADSBCallsign(callsign), + Mode: av.TransponderModeAltitude, + } + + // Preserve the published destination. The selected scenario departure + // definition supplies the exit, SID, and route used to reach it. + ac.InitializeFlightPlan( + av.FlightRulesIFR, + aircraftType, + departureAirport, + destination, + ) + + return s.initializeIFRDepartureNoLock( + ac, + ap, + departureAirport, + runway, + dep, + exitRoutes, + ) +} + +func (s *Sim) departureConfiguration(departureAirport string, runway av.RunwayID, + category string) (*av.Airport, *DepartureRunway, map[av.ExitID]*av.ExitRoute, error) { + ap := s.State.Airports[departureAirport] + if ap == nil { + return nil, nil, nil, av.ErrUnknownAirport + } + + idx := slices.IndexFunc(s.State.DepartureRunways, + func(r DepartureRunway) bool { + return r.Airport == departureAirport && r.Runway == runway && r.Category == category + }) + if idx == -1 { + return nil, nil, nil, av.ErrUnknownRunway + } + rwy := &s.State.DepartureRunways[idx] + return ap, rwy, ap.DepartureRoutes[rwy.Runway], nil +} + +func (s *Sim) initializeIFRDepartureNoLock(ac *Aircraft, ap *av.Airport, departureAirport string, + runway av.RunwayID, dep *av.Departure, exitRoutes map[av.ExitID]*av.ExitRoute) (*Aircraft, error) { exitRoute := exitRoutes[dep.Exit] - err = ac.InitializeDeparture(ap, departureAirport, dep, string(runway), *exitRoute, s.State.NmPerLongitude, + err := ac.InitializeDeparture(ap, departureAirport, dep, string(runway), *exitRoute, s.State.NmPerLongitude, s.State.MagneticVariation, s.wxModel, s.State.SimTime, s.lg) if err != nil { return nil, err } + + // Departures aren't immediately associated, but the STARSComputer will ac.ReportDepartureHeading = exitRoutesHaveVariedHeadings(exitRoutes) ac.ReportDepartureSID = exitRoutesHaveVariedSIDs(exitRoutes) @@ -713,7 +864,7 @@ func (s *Sim) createIFRDepartureNoLock(departureAirport string, runway av.Runway nasFp.EntryFix = ac.FlightPlan.DepartureAirport } nasFp.ExitFix = shortExit - if dep.Scratchpad != "" { // this has top priority + if dep.Scratchpad != "" { nasFp.Scratchpad = dep.Scratchpad } else if sp1 := s.State.FacilityAdaptation.Datablocks.Scratchpad1; sp1.DisplayExitFix || sp1.DisplayExitFix1 || sp1.DisplayExitGate || sp1.DisplayAltExitGate { @@ -728,19 +879,20 @@ func (s *Sim) createIFRDepartureNoLock(departureAirport string, runway av.Runway nasFp.AssignedAltitude = util.Select(!isTRACON, ac.FlightPlan.Altitude, 0) nasFp.RNAV = s.State.FacilityAdaptation.Datablocks.DisplayRNAVSymbol && exitRoute.IsRNAV - ac.HoldForRelease = (ap.HoldForRelease || exitRoute.HoldForRelease) && ac.FlightPlan.Rules == av.FlightRulesIFR // VFRs aren't held + ac.HoldForRelease = (ap.HoldForRelease || exitRoute.HoldForRelease) && ac.FlightPlan.Rules == av.FlightRulesIFR // VFRs aren't held s.assignDepartureController(ac, &nasFp, ap, exitRoute, departureAirport, string(runway)) if err := s.assignSquawk(ac, &nasFp); err != nil { return nil, err } - // Departures aren't immediately associated, but the STARSComputer will - // hold on to their flight plans for now. - // Create a flight strip for departures + + // Departures aren't immediately associated, but the STARSComputer will + // hold on to their flight plans for now. + // Create a flight strip for departures if shouldCreateFlightStrip(&nasFp) { if s.isVirtualController(nasFp.TrackingController) { - // Virtual controller: strip goes to the handoff target + // Virtual controller: strip goes to the handoff target if !s.isVirtualController(nasFp.InboundHandoffController) { s.initFlightStrip(&nasFp, nasFp.InboundHandoffController) } @@ -751,7 +903,6 @@ func (s *Sim) createIFRDepartureNoLock(departureAirport string, runway av.Runway } _, err = s.STARSComputer.CreateFlightPlan(nasFp) - return ac, err } @@ -781,11 +932,24 @@ func (s *Sim) CreateVFRDeparture(departureAirport string) (*Aircraft, error) { return nil, nil } -func makeDepartureAircraft(ac *Aircraft, simTime Time, model *wx.Model, r *rand.Rand) DepartureAircraft { +func departureGateDelay(ac *Aircraft, trafficSource TrafficSource, r *rand.Rand) time.Duration { + if ac.FlightPlan.Rules != av.FlightRulesIFR { + return 0 + } + + if trafficSource == TrafficSourceRealWorldSchedule { + return r.DurationRange(10*time.Minute, 21*time.Minute) + } + + return 5 * time.Minute +} + +func makeDepartureAircraft(ac *Aircraft, simTime Time, model *wx.Model, trafficSource TrafficSource, + r *rand.Rand) DepartureAircraft { d := DepartureAircraft{ ADSBCallsign: ac.ADSBCallsign, SpawnTime: simTime, - ReadyDepartGateTime: simTime.Add(5 * time.Minute), + ReadyDepartGateTime: simTime.Add(departureGateDelay(ac, trafficSource, r)), } // Simulate out the takeoff roll and initial climb to figure out when diff --git a/sim/spawn_departures_test.go b/sim/spawn_departures_test.go index 433a35f4d..79b44e223 100644 --- a/sim/spawn_departures_test.go +++ b/sim/spawn_departures_test.go @@ -240,3 +240,4 @@ func TestSamePavementRunways(t *testing.T) { t.Errorf("samePavementRunways = %v, shouldn't include intersecting runway 36", got) } } + diff --git a/sim/spawn_pattern.go b/sim/spawn_pattern.go index afcbc20a5..c6a0f9495 100644 --- a/sim/spawn_pattern.go +++ b/sim/spawn_pattern.go @@ -243,7 +243,7 @@ func (s *Sim) spawnPatternAircraft() { s.addAircraftNoLock(*ac) // Record as a departure for sequencing - depac := makeDepartureAircraft(ac, now, s.wxModel, s.Rand) + depac := makeDepartureAircraft(ac, now, s.wxModel, TrafficSourceRandom, s.Rand) depac.LaunchTime = now for rwyID, depState := range s.DepartureState[name] { if rwyID.Base() == rwy.Id { diff --git a/sim/traffic_provider.go b/sim/traffic_provider.go new file mode 100644 index 000000000..92778272d --- /dev/null +++ b/sim/traffic_provider.go @@ -0,0 +1,342 @@ +// sim/traffic_provider.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "errors" + "fmt" + "sort" + "time" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/util" +) + +var errScheduledDepartureRunwayMismatch = errors.New( + "scheduled departure is not compatible with this runway", +) + +// trafficProvider supplies automatically generated IFR aircraft to the +// simulation. It also controls when the next departure request should occur; +// random traffic uses a rate-based delay while schedule traffic uses the next +// published pushback time plus simulated taxi-out time. +type trafficProvider interface { + createIFRDeparture(s *Sim, airport string, runway av.RunwayID) (*Aircraft, time.Duration, error) + createInbound(s *Sim, group string, rates map[string]float32, pushActive bool) (*Aircraft, time.Duration, error) +} + +// randomTrafficProvider preserves Vice's existing rate-based random traffic +// generation behavior. +type randomTrafficProvider struct{} + +func (randomTrafficProvider) createIFRDeparture(s *Sim, airport string, runway av.RunwayID) (*Aircraft, time.Duration, error) { + ac, err := s.makeNewIFRDeparture(airport, runway) + depState := s.DepartureState[airport][runway] + return ac, randomWait(depState.IFRSpawnRate, false, s.Rand), err +} + +func (randomTrafficProvider) createInbound(s *Sim, group string, + rates map[string]float32, pushActive bool) (*Aircraft, time.Duration, error) { + flow, rateSum := sampleRateMap( + rates, + s.State.LaunchConfig.InboundFlowRateScale, + s.Rand, + ) + + delay := randomWait(rateSum, pushActive, s.Rand) + + if flow == "overflights" { + ac, err := s.createOverflightNoLock(group) + return ac, delay, err + } + + ac, err := s.createArrivalNoLock(group, flow) + return ac, delay, err +} + +type scheduledDeparture struct { + flight ScheduledFlight + offset time.Duration +} + +type scheduledArrival struct { + flight ScheduledFlight + offset time.Duration +} + +func includeScheduledFlight(flight ScheduledFlight, percentage int) bool { + percentage = min(max(percentage, 0), 100) + + // Zero explicitly disables this direction, including cargo. + if percentage == 0 { + return false + } + + if percentage == 100 || flight.Cargo { + return true + } + + // Use a stable hash so the same percentage consistently selects the same + // flights each time the scenario is loaded. + hash := uint32(2166136261) + key := flight.Callsign + flight.Origin + flight.Destination + + for i := 0; i < len(key); i++ { + hash ^= uint32(key[i]) + hash *= 16777619 + } + + return int(hash%100) < percentage +} + +// scheduleTrafficProvider emits departures in runway-ready order. Published +// departure times are treated as pushback times; a random 10-20 minute taxi-out +// duration is generated once per departure when the provider is first used. +// The schedule clock starts at ScheduleStartMinute when the provider is created. +type scheduleTrafficProvider struct { + airport string + start Time + + departures []scheduledDeparture + nextDeparture int + + arrivals []scheduledArrival + nextArrival int +} + +func newScheduleTrafficProvider( + schedule BuiltInSchedule, + startMinute int, + start Time, + arrivalPercentage int, + departurePercentage int, +) *scheduleTrafficProvider { + p := &scheduleTrafficProvider{airport: schedule.Airport, start: start} + for _, flight := range schedule.Flights { + minutes := (flight.ScheduledMinute - startMinute + 24*60) % (24 * 60) + offset := time.Duration(minutes) * time.Minute + + switch flight.OperationAt(schedule.Airport) { + case ScheduleOperationDeparture: + if includeScheduledFlight(flight, departurePercentage) { + p.departures = append(p.departures, scheduledDeparture{ + flight: flight, + offset: offset, + }) + } + + case ScheduleOperationArrival: + if includeScheduledFlight(flight, arrivalPercentage) { + p.arrivals = append(p.arrivals, scheduledArrival{ + flight: flight, + offset: offset, + }) + } + } + } + sort.SliceStable(p.departures, func(i, j int) bool { + return p.departures[i].offset < p.departures[j].offset + }) + + sort.SliceStable(p.arrivals, func(i, j int) bool { + return p.arrivals[i].offset < p.arrivals[j].offset + }) + + return p + +} + +// initializeDepartures generates one taxi-out duration for each scheduled +// departure and orders the departures by the time they reach the runway queue. +// It runs only once, so taxi times remain fixed for the life of the scenario. + +func (p *scheduleTrafficProvider) createIFRDeparture( + s *Sim, + airport string, + runway av.RunwayID, +) (*Aircraft, time.Duration, error) { + const idleDelay = 365 * 24 * time.Hour + + if airport != p.airport || p.nextDeparture >= len(p.departures) { + return nil, idleDelay, nil + } + + scheduled := p.departures[p.nextDeparture] + due := p.start.Add(scheduled.offset) + + if s.State.SimTime.Before(due) { + return nil, due.Sub(s.State.SimTime), nil + } + + rates := s.State.LaunchConfig.DepartureRates[airport][runway] + category, rateSum := sampleRateMap( + rates, + s.State.LaunchConfig.DepartureRateScale, + s.Rand, + ) + + if rateSum == 0 { + return nil, time.Second, nil + } + + ac, err := s.createScheduledIFRDepartureNoLock( + scheduled.flight, + airport, + runway, + category, + ) + + if errors.Is(err, errScheduledDepartureRunwayMismatch) { + // This runway does not support the scheduled destination. Keep the + // flight at the front of the queue so another active runway can claim it. + return nil, time.Second, nil + } + + // The flight has either spawned successfully or failed for a permanent + // reason such as an invalid callsign/type or an unmodeled destination. + p.nextDeparture++ + + delay := idleDelay + if p.nextDeparture < len(p.departures) { + nextDue := p.start.Add(p.departures[p.nextDeparture].offset) + delay = max(time.Millisecond, nextDue.Sub(s.State.SimTime)) + } + + return ac, delay, err +} + +func (p *scheduleTrafficProvider) createInbound(s *Sim, group string, + rates map[string]float32, pushActive bool) (*Aircraft, time.Duration, error) { + const idleDelay = 365 * 24 * time.Hour + + if p.nextArrival >= len(p.arrivals) { + // Real-world schedules currently provide arrivals and departures only. + // Continue generating random overflights when they are enabled. + if _, ok := rates["overflights"]; ok { + return randomTrafficProvider{}.createInbound( + s, + group, + map[string]float32{"overflights": rates["overflights"]}, + pushActive, + ) + } + return nil, idleDelay, nil + } + + scheduled := p.arrivals[p.nextArrival] + due := p.start.Add(scheduled.offset) + if s.State.SimTime.Before(due) { + return nil, due.Sub(s.State.SimTime), nil + } + + // Determine which scenario inbound flow contains a route from the + // published origin to the schedule airport. + matchedGroup := "" + for candidateGroup, inboundFlow := range s.State.InboundFlows { + if _, err := resolveScheduledArrival( + inboundFlow.Arrivals, + p.airport, + scheduled.flight.Origin, + ); err == nil { + matchedGroup = candidateGroup + break + } + } + + if matchedGroup == "" { + p.nextArrival++ + delay := idleDelay + if p.nextArrival < len(p.arrivals) { + nextDue := p.start.Add(p.arrivals[p.nextArrival].offset) + delay = max(time.Millisecond, nextDue.Sub(s.State.SimTime)) + } + return nil, delay, fmt.Errorf( + "%s: no inbound flow from %s to %s", + scheduled.flight.Callsign, + scheduled.flight.Origin, + p.airport, + ) + } + + // Each inbound-flow timer calls this provider independently. Only the + // matching flow should create this scheduled arrival. + if group != matchedGroup { + s.lg.Infof( + "scheduled arrival %s waiting for flow %s (currently %s)", + scheduled.flight.Callsign, + matchedGroup, + group, + ) + return nil, time.Second, nil + } + + if rate, ok := rates[p.airport]; !ok || + scaleRate(rate, s.State.LaunchConfig.InboundFlowRateScale) == 0 { + return nil, time.Second, nil + } + + ac, err := s.createScheduledArrivalNoLock( + scheduled.flight, + group, + p.airport, + ) + if errors.Is(err, errScheduledArrivalSpawnConflict) { + // Keep this arrival at the head of the queue and retry shortly. This + // preserves schedule order while allowing the preceding arrival to + // move at least 10 NM away from the common spawn point. + return nil, 5 * time.Second, nil + } + p.nextArrival++ // unmatched aircraft are skipped and reported by the caller + + delay := idleDelay + if p.nextArrival < len(p.arrivals) { + nextDue := p.start.Add(p.arrivals[p.nextArrival].offset) + delay = max(time.Millisecond, nextDue.Sub(s.State.SimTime)) + } + + return ac, delay, err +} + +type errorTrafficProvider struct{ err error } + +func (p errorTrafficProvider) createIFRDeparture(_ *Sim, _ string, _ av.RunwayID) (*Aircraft, time.Duration, error) { + return nil, time.Minute, p.err +} +func (p errorTrafficProvider) createInbound(_ *Sim, _ string, + _ map[string]float32, _ bool) (*Aircraft, time.Duration, error) { + return nil, time.Minute, p.err +} + +func (s *Sim) activeTrafficProvider() trafficProvider { + if s.trafficProvider != nil { + return s.trafficProvider + } + + if s.State.LaunchConfig.TrafficSource != TrafficSourceRealWorldSchedule { + s.trafficProvider = randomTrafficProvider{} + return s.trafficProvider + } + + catalog, err := LoadBuiltInScheduleCatalog(util.GetResourcesFS(), "schedules") + if err != nil { + s.trafficProvider = errorTrafficProvider{err: err} + return s.trafficProvider + } + schedule, ok := catalog.Find(s.State.PrimaryAirport, s.State.LaunchConfig.ScheduleID) + if !ok { + s.trafficProvider = errorTrafficProvider{err: fmt.Errorf("real-world schedule %q not found for %s", + s.State.LaunchConfig.ScheduleID, s.State.PrimaryAirport)} + return s.trafficProvider + } + + s.trafficProvider = newScheduleTrafficProvider( + schedule, + int(s.State.LaunchConfig.ScheduleStartMinute), + s.scheduleStart, + s.State.LaunchConfig.ScheduleArrivalPercentage, + s.State.LaunchConfig.ScheduleDeparturePercentage, + ) + return s.trafficProvider +} diff --git a/sim/traffic_provider_test.go b/sim/traffic_provider_test.go new file mode 100644 index 000000000..9c4009856 --- /dev/null +++ b/sim/traffic_provider_test.go @@ -0,0 +1,167 @@ +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "testing" + "time" +) + +func TestScheduleTrafficProviderOrdersDeparturesFromSelectedStartTime(t *testing.T) { + start := NewSimTime(time.Date(2026, time.July, 14, 14, 0, 0, 0, time.Local)) + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + {Callsign: "DAL2", Origin: "KMSP", Destination: "KATL", ScheduledMinute: 14*60 + 10}, + {Callsign: "DAL1", Origin: "KMSP", Destination: "KORD", ScheduledMinute: 14*60 + 2}, + {Callsign: "DAL0", Origin: "KMSP", Destination: "KDEN", ScheduledMinute: 13*60 + 55}, + {Callsign: "DAL3", Origin: "KATL", Destination: "KMSP", ScheduledMinute: 14*60 + 1}, + }, + } + + provider := newScheduleTrafficProvider( + schedule, + 14*60, + start, + 100, + 100, + ) + if len(provider.departures) != 3 { + t.Fatalf("got %d departures, want 3", len(provider.departures)) + } + + want := []struct { + callsign string + offset time.Duration + }{ + {"DAL1", 2 * time.Minute}, + {"DAL2", 10 * time.Minute}, + {"DAL0", 23*time.Hour + 55*time.Minute}, + } + for i, expected := range want { + got := provider.departures[i] + if got.flight.Callsign != expected.callsign || got.offset != expected.offset { + t.Errorf("departure %d = %s at %s, want %s at %s", i, got.flight.Callsign, got.offset, + expected.callsign, expected.offset) + } + } +} + +func TestScheduleTrafficProviderOrdersArrivalsFromSelectedStartTime(t *testing.T) { + start := NewSimTime(time.Date(2026, time.July, 14, 14, 0, 0, 0, time.Local)) + schedule := BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{ + {Callsign: "DAL102", Origin: "KATL", Destination: "KMSP", ScheduledMinute: 14*60 + 12}, + {Callsign: "DAL101", Origin: "KORD", Destination: "KMSP", ScheduledMinute: 14*60 + 3}, + {Callsign: "DAL100", Origin: "KDEN", Destination: "KMSP", ScheduledMinute: 13*60 + 50}, + {Callsign: "DAL200", Origin: "KMSP", Destination: "KATL", ScheduledMinute: 14*60 + 1}, + }, + } + + provider := newScheduleTrafficProvider( + schedule, + 14*60, + start, + 100, + 100, + ) + if len(provider.arrivals) != 3 { + t.Fatalf("got %d arrivals, want 3", len(provider.arrivals)) + } + + want := []struct { + callsign string + offset time.Duration + }{ + {"DAL101", 3 * time.Minute}, + {"DAL102", 12 * time.Minute}, + {"DAL100", 23*time.Hour + 50*time.Minute}, + } + + for i, expected := range want { + got := provider.arrivals[i] + if got.flight.Callsign != expected.callsign || got.offset != expected.offset { + t.Errorf( + "arrival %d = %s at %s, want %s at %s", + i, + got.flight.Callsign, + got.offset, + expected.callsign, + expected.offset, + ) + } + } +} + +func TestScheduleTrafficProviderWaitsForPublishedArrivalTime(t *testing.T) { + start := NewSimTime(time.Date(2026, time.July, 14, 14, 0, 0, 0, time.Local)) + provider := newScheduleTrafficProvider(BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{{ + Callsign: "DAL321", + Origin: "KORD", + Destination: "KMSP", + AircraftType: "A320", + ScheduledMinute: 14*60 + 7, + }}, + }, + 14*60, + start, + 100, + 100, + ) + + s := &Sim{ + State: &CommonState{ + DynamicState: DynamicState{ + SimTime: start, + }, + }, + } + + ac, delay, err := provider.createInbound( + s, + "TEST", + map[string]float32{"KMSP": 10}, + false, + ) + if err != nil { + t.Fatalf("createInbound: %v", err) + } + if ac != nil { + t.Fatal("created arrival before its published time") + } + if delay != 7*time.Minute { + t.Fatalf("delay = %s, want 7m", delay) + } +} + +func TestScheduleTrafficProviderWaitsForPublishedTime(t *testing.T) { + start := NewSimTime(time.Date(2026, time.July, 14, 14, 0, 0, 0, time.Local)) + provider := newScheduleTrafficProvider(BuiltInSchedule{ + Airport: "KMSP", + Flights: []ScheduledFlight{{ + Callsign: "DAL123", Origin: "KMSP", Destination: "KORD", AircraftType: "A320", + ScheduledMinute: 14*60 + 5, + }}, + }, + 14*60, + start, + 100, + 100, + ) + + s := &Sim{State: &CommonState{DynamicState: DynamicState{SimTime: start}}} + ac, delay, err := provider.createIFRDeparture(s, "KMSP", "12L") + if err != nil { + t.Fatalf("createIFRDeparture: %v", err) + } + if ac != nil { + t.Fatal("created departure before its published time") + } + if delay != 5*time.Minute { + t.Fatalf("delay = %s, want 5m", delay) + } +} diff --git a/website/facility-engineering.html b/website/facility-engineering.html index adc947314..acecbb668 100644 --- a/website/facility-engineering.html +++ b/website/facility-engineering.html @@ -187,6 +187,7 @@
+ vice supports built-in real-world airline schedules as an alternative + to the procedurally generated traffic normally defined in scenario files. + Each airport may provide one or more schedules, allowing facilities to + offer different traffic sets such as weekday, weekend, or seasonal + operations. +
+ +
+ Schedule metadata is stored in
+ resources/schedules/<ICAO>/schedules.json. Each manifest
+ entry references a CSV file containing scheduled flights. The airport is
+ inferred from the directory name, so it does not need to be specified in
+ the manifest itself.
+
+ Each scheduled flight specifies a callsign, aircraft type, origin, + destination, scheduled time, and whether it is a cargo flight. When a + schedule is selected, vice generates departures, arrivals, and + overflights for the active scenario from this data. +
+ ++ If a scheduled flight's origin or destination matches an airport already + defined in the scenario, vice automatically reuses the existing + departure and arrival routes from that scenario. This allows real-world + schedules to integrate with existing scenario definitions without requiring + duplicate route information in the schedule data. +
+vice allows the specification of VFR "distractor" aircraft in scenarios;