diff --git a/aviation/aviation.go b/aviation/aviation.go index 7fe56d0b9..0b0ef82fb 100644 --- a/aviation/aviation.go +++ b/aviation/aviation.go @@ -127,6 +127,7 @@ type Arrival struct { type AirlineSpecifier struct { ICAO string `json:"icao"` + Callsign string `json:"callsign,omitempty"` Fleet string `json:"fleet,omitempty"` AircraftTypes []string `json:"types,omitempty"` } @@ -159,22 +160,64 @@ func (a AirlineSpecifier) Aircraft() []FleetAircraft { } } +func CallsignClashesWithExisting(currentCallsigns []ADSBCallsign, proposed string, uniqueSuffix bool) bool { + if uniqueSuffix { + // Reject if the last 2 characters of callsign match an existing callsign. + suffixMatches := func(cs ADSBCallsign) bool { + return len(proposed) >= 2 && strings.HasSuffix(string(cs), proposed[len(proposed)-2:]) + } + return slices.ContainsFunc(currentCallsigns, suffixMatches) + } + // Reject only if there's an exact match + return slices.Contains(currentCallsigns, ADSBCallsign(proposed)) +} + func (a *AirlineSpecifier) Check(e *util.ErrorLogger) { defer e.CheckDepth(e.CurrentDepth()) e.Push("Airline " + a.ICAO) defer e.Pop() - al, ok := DB.Airlines[strings.ToUpper(a.ICAO)] - if !ok { - e.ErrorString("airline not known") - return + a.ICAO = strings.ToUpper(strings.TrimSpace(a.ICAO)) + a.Callsign = strings.ToUpper(strings.TrimSpace(a.Callsign)) + + if a.Callsign != "" { + for _, ch := range a.Callsign { + if (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') { + e.ErrorString("callsign has invalid character %q", ch) + break + } + } + } + + if a.ICAO == "" && a.Callsign != "" { + if icao := icaoFromCallsign(a.Callsign); icao != "" { + a.ICAO = icao + } + } + + var al Airline + if a.ICAO != "" { + var ok bool + al, ok = DB.Airlines[a.ICAO] + if !ok { + e.ErrorString("airline not known") + return + } } if a.Fleet == "" && len(a.AircraftTypes) == 0 { + if a.ICAO == "" { + e.ErrorString("must specify \"types\" when no \"icao\" is provided") + return + } a.Fleet = "default" } if a.Fleet != "" { + if a.ICAO == "" { + e.ErrorString("must specify \"icao\" when \"fleet\" is set") + return + } if len(a.AircraftTypes) != 0 { e.ErrorString("cannot specify both \"fleet\" and \"types\"") return @@ -203,6 +246,84 @@ func (a *AirlineSpecifier) Check(e *util.ErrorLogger) { } } +func (a AirlineSpecifier) sampleAcType(r *rand.Rand, departureAirport, arrivalAirport string, lg *log.Logger) string { + if a.ICAO == "" { + if len(a.AircraftTypes) == 0 { + lg.Errorf("No aircraft types available for callsign %q", a.Callsign) + return "" + } + actype := rand.SampleSlice(r, a.AircraftTypes) + if _, ok := DB.AircraftPerformance[actype]; !ok { + lg.Errorf("Aircraft %q not found in performance database for callsign %q", actype, a.Callsign) + return "" + } + return actype + } + if _, ok := DB.Airlines[strings.ToUpper(a.ICAO)]; !ok { + // TODO: this should be caught at load validation time... + lg.Errorf("Airline %q not found in database", a.ICAO) + return "" + } + + // Calculate flight distance to filter aircraft by CWT category + dep, arr := DB.Airports[departureAirport], DB.Airports[arrivalAirport] + flightDistance := math.NMDistance2LL(dep.Location, arr.Location) + + // Sample according to fleet count, filtering by maximum distance for CWT category + var actype string + + // First attempt: filter aircraft by distance and sample weighted by fleet count + filteredAircraft := make([]FleetAircraft, 0) + for _, ac := range a.Aircraft() { + // Filter based on flight distance and aircraft CWT category + if flightDistance > 0 && !slices.Contains(extraLongRange, ac.ICAO) { + if perf, ok := DB.AircraftPerformance[ac.ICAO]; ok { + if maxRange, ok := cwtMaxRanges[perf.Category.CWT]; ok { + // Check if flight distance exceeds category maximum (0 means no limit) + if maxRange > 0 && flightDistance > maxRange { + continue + } + } + } + } + filteredAircraft = append(filteredAircraft, ac) + } + + if len(filteredAircraft) > 0 { + sampled, ok := rand.SampleWeighted(r, filteredAircraft, func(ac FleetAircraft) float32 { + return float32(ac.Count) + }) + + if ok { + actype = sampled.ICAO + } + } + + if actype == "" { + // Try again without considering range. + sampled, ok := rand.SampleWeighted(r, a.Aircraft(), func(ac FleetAircraft) float32 { + return float32(ac.Count) + }) + + if ok { + actype = sampled.ICAO + } + } + if actype != "" { + if _, ok := DB.AircraftPerformance[actype]; !ok { + // TODO: validation stage... + lg.Errorf("Aircraft %q not found in performance database for airline %+v", + actype, a) + return "" + } + } + return actype +} + +func (a AirlineSpecifier) SampleAcType(r *rand.Rand, departureAirport, arrivalAirport string, lg *log.Logger) string { + return a.sampleAcType(r, departureAirport, arrivalAirport, lg) +} + var badCallsigns map[string]any = map[string]any{ // 9/11 "AAL11": nil, @@ -262,68 +383,30 @@ var extraLongRange = []string{"A35K", "A359"} // currentCallsigns will be empty if we don't care about unique suffixes. func (a AirlineSpecifier) SampleAcTypeAndCallsign(r *rand.Rand, currentCallsigns []ADSBCallsign, uniqueSuffix bool, departureAirport, arrivalAirport string, lg *log.Logger) (actype, callsign string) { - dbAirline, ok := DB.Airlines[strings.ToUpper(a.ICAO)] - if !ok { - // TODO: this should be caught at load validation time... - lg.Errorf("Airline %q not found in database", a.ICAO) + actype = a.sampleAcType(r, departureAirport, arrivalAirport, lg) + if actype == "" { return "", "" } - // Calculate flight distance to filter aircraft by CWT category - dep, arr := DB.Airports[departureAirport], DB.Airports[arrivalAirport] - flightDistance := math.NMDistance2LL(dep.Location, arr.Location) - - // Sample according to fleet count, filtering by maximum distance for CWT category - acCount := 0 - for _, ac := range a.Aircraft() { - // Filter based on flight distance and aircraft CWT category - if flightDistance > 0 && !slices.Contains(extraLongRange, ac.ICAO) { - if perf, ok := DB.AircraftPerformance[ac.ICAO]; ok { - if maxRange, ok := cwtMaxRanges[perf.Category.CWT]; ok { - // Check if flight distance exceeds category maximum (0 means no limit) - if maxRange > 0 && flightDistance > maxRange { - continue - } - } - } + if a.Callsign != "" { + callsign = strings.ToUpper(strings.TrimSpace(a.Callsign)) + if callsign == "" { + return "", "" } - - // Reservoir sampling... - acCount += ac.Count - if r.Float32() < float32(ac.Count)/float32(acCount) { - actype = ac.ICAO + if _, ok := badCallsigns[callsign]; ok { + return "", "" } - } - if actype == "" { - // Try again without considering range. - for _, ac := range a.Aircraft() { - acCount += ac.Count - if r.Float32() < float32(ac.Count)/float32(acCount) { - actype = ac.ICAO - } + if CallsignClashesWithExisting(currentCallsigns, callsign, uniqueSuffix) { + return "", "" } + return actype, callsign } - if _, ok := DB.AircraftPerformance[actype]; !ok { - // TODO: validation stage... - lg.Errorf("Aircraft %q not found in performance database for airline %+v", - actype, a) + dbAirline, ok := DB.Airlines[strings.ToUpper(a.ICAO)] + if !ok { return "", "" } - callsignClashesWithExisting := func(proposed string) bool { - if uniqueSuffix { - // Reject if the last 2 characters of callsign match an existing callsign. - suffixMatches := func(cs ADSBCallsign) bool { - return strings.HasSuffix(string(cs), proposed[len(proposed)-2:]) - } - return slices.ContainsFunc(currentCallsigns, suffixMatches) - } else { - // Reject only if there's an exact match - return slices.Contains(currentCallsigns, ADSBCallsign(proposed)) - } - } - // random callsign var cs strings.Builder for range 100 { @@ -367,7 +450,7 @@ func (a AirlineSpecifier) SampleAcTypeAndCallsign(r *rand.Rand, currentCallsigns } else if slices.Contains(currentCallsigns, ADSBCallsign(cs.String())) { cs.Reset() continue - } else if callsignClashesWithExisting(cs.String()) { + } else if CallsignClashesWithExisting(currentCallsigns, cs.String(), uniqueSuffix) { cs.Reset() continue } @@ -377,6 +460,19 @@ func (a AirlineSpecifier) SampleAcTypeAndCallsign(r *rand.Rand, currentCallsigns return "", "" } +func icaoFromCallsign(callsign string) string { + if len(callsign) < 3 { + return "" + } + for i := range 3 { + ch := callsign[i] + if ch < 'A' || ch > 'Z' { + return "" + } + } + return callsign[:3] +} + type Runway struct { Id string Heading float32 @@ -733,6 +829,11 @@ func (ar *Arrival) PostDeserialize(loc Locator, nmPerLongitude float32, magnetic } } + if len(ar.Airlines) == 0 { + e.ErrorString("no \"airlines\" specified for arrivals") + return + } + for icao := range ar.Airlines { airport, ok := DB.Airports[icao] if !ok { @@ -930,7 +1031,9 @@ func (ar *Arrival) PostDeserialize(loc Locator, nmPerLongitude float32, magnetic "airport %q is listed in \"expect_approach\" but is not in arrival airports", airport, ) - } else if ap, ok := airports[airport]; ok { + continue + } + if ap, ok := airports[airport]; ok { if _, ok := ap.Approaches[appr]; !ok { e.ErrorString( "arrival airport %q doesn't have a %q approach for \"expect_approach\"", diff --git a/sim/spawn_arrivals.go b/sim/spawn_arrivals.go index b6608f068..f8ca9d764 100644 --- a/sim/spawn_arrivals.go +++ b/sim/spawn_arrivals.go @@ -10,6 +10,7 @@ import ( "maps" gomath "math" "slices" + "strings" "time" av "github.com/mmp/vice/aviation" @@ -101,14 +102,31 @@ func (s *Sim) createArrivalNoLock(group string, arrivalAirport string) (*Aircraf } arr := arrivals[idx] - airline := rand.SampleSlice(s.Rand, arr.Airlines[arrivalAirport]) - ac, acType := s.sampleAircraft(airline.AirlineSpecifier, airline.Airport, arrivalAirport, s.lg) + airlines := arr.Airlines[arrivalAirport] + if len(airlines) == 0 { + return nil, fmt.Errorf("no airlines for arrival group %s airport %s", group, arrivalAirport) + } + + var ac *Aircraft + var acType string + var departureAirport string + for range 50 { + airline := rand.SampleSlice(s.Rand, airlines) + departureAirport = airline.Airport + if airline.Callsign != "" { + ac, acType = s.sampleAircraftWithAirlineCallsign(airline.AirlineSpecifier, departureAirport, arrivalAirport, s.lg) + } else { + ac, acType = s.sampleAircraft(airline.AirlineSpecifier, departureAirport, arrivalAirport, s.lg) + } + if ac != nil { + break + } + } if ac == nil { - return nil, fmt.Errorf("unable to sample a valid aircraft for airline %+v at %q", airline, - arrivalAirport) + return nil, fmt.Errorf("unable to sample a valid aircraft for arrivals to %q", arrivalAirport) } - ac.InitializeFlightPlan(av.FlightRulesIFR, acType, airline.Airport, arrivalAirport) + ac.InitializeFlightPlan(av.FlightRulesIFR, acType, departureAirport, arrivalAirport) err := ac.InitializeArrival(s.State.Airports[arrivalAirport], &arr, s.State.NmPerLongitude, s.State.MagneticVariation, s.wxModel, s.State.SimTime, s.lg) @@ -157,12 +175,17 @@ func (s *Sim) createArrivalNoLock(group string, arrivalAirport string) (*Aircraf return ac, err } -func (s *Sim) sampleAircraft(al av.AirlineSpecifier, departureAirport, arrivalAirport string, lg *log.Logger) (*Aircraft, string) { - // Collect all currently in-use or soon-to-be in-use callsigns. +func (s *Sim) currentCallsigns() []av.ADSBCallsign { callsigns := slices.Collect(maps.Keys(s.Aircraft)) for _, fp := range s.STARSComputer.FlightPlans { callsigns = append(callsigns, av.ADSBCallsign(fp.ACID)) } + return callsigns +} + +func (s *Sim) sampleAircraft(al av.AirlineSpecifier, departureAirport, arrivalAirport string, lg *log.Logger) (*Aircraft, string) { + // Collect all currently in-use or soon-to-be in-use callsigns. + callsigns := s.currentCallsigns() actype, callsign := al.SampleAcTypeAndCallsign(s.Rand, callsigns, s.EnforceUniqueCallsignSuffix, departureAirport, arrivalAirport, lg) @@ -175,6 +198,27 @@ func (s *Sim) sampleAircraft(al av.AirlineSpecifier, departureAirport, arrivalAi Mode: av.TransponderModeAltitude, }, actype } +func (s *Sim) sampleAircraftWithAirlineCallsign(al av.AirlineSpecifier, departureAirport, arrivalAirport string, lg *log.Logger) (*Aircraft, string) { + callsign := strings.ToUpper(strings.TrimSpace(al.Callsign)) + if callsign == "" { + return nil, "" + } + + callsigns := s.currentCallsigns() + if av.CallsignClashesWithExisting(callsigns, callsign, s.EnforceUniqueCallsignSuffix) { + return nil, "" + } + + actype := al.SampleAcType(s.Rand, departureAirport, arrivalAirport, lg) + if actype == "" { + return nil, "" + } + + return &Aircraft{ + ADSBCallsign: av.ADSBCallsign(callsign), + Mode: av.TransponderModeAltitude, + }, actype +} // assignSquawk allocates an enroute squawk code and assigns it to both the // aircraft and NAS flight plan. @@ -258,13 +302,32 @@ func (s *Sim) createOverflightNoLock(group string) (*Aircraft, error) { overflights := s.State.InboundFlows[group].Overflights of := rand.SampleSlice(s.Rand, overflights) - airline := rand.SampleSlice(s.Rand, of.Airlines) - ac, acType := s.sampleAircraft(airline.AirlineSpecifier, airline.DepartureAirport, airline.ArrivalAirport, s.lg) + if len(of.Airlines) == 0 { + return nil, fmt.Errorf("no airlines for overflights in %q", group) + } + + var ac *Aircraft + var acType string + var departureAirport string + var arrivalAirport string + for range 50 { + airline := rand.SampleSlice(s.Rand, of.Airlines) + departureAirport = airline.DepartureAirport + arrivalAirport = airline.ArrivalAirport + if airline.Callsign != "" { + ac, acType = s.sampleAircraftWithAirlineCallsign(airline.AirlineSpecifier, departureAirport, arrivalAirport, s.lg) + } else { + ac, acType = s.sampleAircraft(airline.AirlineSpecifier, departureAirport, arrivalAirport, s.lg) + } + if ac != nil { + break + } + } if ac == nil { - return nil, fmt.Errorf("unable to sample a valid aircraft for overflight %+v in %q", airline, group) + return nil, fmt.Errorf("unable to sample a valid aircraft for overflight in %q", group) } - ac.InitializeFlightPlan(av.FlightRulesIFR, acType, airline.DepartureAirport, airline.ArrivalAirport) + ac.InitializeFlightPlan(av.FlightRulesIFR, acType, departureAirport, arrivalAirport) if err := ac.InitializeOverflight(&of, s.State.NmPerLongitude, s.State.MagneticVariation, s.wxModel, s.State.SimTime, s.lg); err != nil { diff --git a/sim/spawn_departures.go b/sim/spawn_departures.go index 9c3e129e3..1ef058911 100644 --- a/sim/spawn_departures.go +++ b/sim/spawn_departures.go @@ -615,11 +615,25 @@ func (s *Sim) createIFRDepartureNoLock(departureAirport, runway, category string } dep := &ap.Departures[idx] - airline := rand.SampleSlice(s.Rand, dep.Airlines) - ac, acType := s.sampleAircraft(airline.AirlineSpecifier, departureAirport, dep.Destination, s.lg) + if len(dep.Airlines) == 0 { + return nil, fmt.Errorf("no airlines for departure at %q", departureAirport) + } + + var ac *Aircraft + var acType string + for range 50 { + airline := rand.SampleSlice(s.Rand, dep.Airlines) + if airline.Callsign != "" { + ac, acType = s.sampleAircraftWithAirlineCallsign(airline.AirlineSpecifier, departureAirport, dep.Destination, s.lg) + } else { + ac, acType = s.sampleAircraft(airline.AirlineSpecifier, departureAirport, dep.Destination, s.lg) + } + if ac != nil { + break + } + } if ac == nil { - return nil, fmt.Errorf("unable to sample a valid aircraft for airline %+v at %q", airline, - departureAirport) + return nil, fmt.Errorf("unable to sample a valid aircraft for departures at %q", departureAirport) } ac.InitializeFlightPlan(av.FlightRulesIFR, acType, departureAirport, dep.Destination) diff --git a/website/index.html b/website/index.html index 828132b27..9ce954fba 100644 --- a/website/index.html +++ b/website/index.html @@ -6737,6 +6737,11 @@

Airlines and Aircraft

String The ICAO code of the airline (which must be present in vice's openscope-airlines.json file.) + + "callsign" + String + A specific callsign (eg. AAL123) to use for an aircraft flying this route. (Both callsigns and ICAO cannot be specified at the same time.) + "fleet" String @@ -7313,6 +7318,12 @@

Departures

"icao": "AFR" } ], + "callsigns": [ + { + "callsign": "AFR1021", + "types": ["A320"] + } + ], "destination": "LPFG", "exit": "HAPIE", "route": "HAPIE YAHOO WHALE N251A JOOPY NATZ MALOT NATZ GISTI LESLU M142 LND N160 NAKID M25 ANNET UM25 UVSUV UM25 INGOR UM25 LUKIP LUKIP9E", @@ -7336,6 +7347,7 @@

Departures

Each object specifies an airline and the types of aircraft it flies on the route. See Airlines and Aircraft for the form of the airline objects. + "destination" String @@ -7432,6 +7444,7 @@

Arrivals

"icao", "fleet", and "types" members of the airline object documented in Airlines and Aircraft as well as some additional fields. See the example below. + "assigned_altitude" Number @@ -7659,6 +7672,7 @@

Overflights

Specification of the airlines flying the overflight and their departure/arrival airports. See the example below. + "assigned_altitude" Number