diff --git a/TODO.md b/TODO.md index 947f4e43f3..12ef0fe1b8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,13 @@ # TODO items +## Core +core.base.reset: call Base.reset for each derived class? +client-side reset is not called for everything? + ## Network branch - Move ECHO to client -- What to do with sharedstates that make changes both ways (like pan/zoom)? Is there more than pan/zoom? Otherwise a different approach can also be taken - Where are pan/zoom used in sim? - = implemented in screen(io).getviewctr() and getviewbounds() - - MCRE - - waypoint lookup - Possible approach: add explicit (but optional?) reflat/reflon args to these stack functions, with stack implementations on both sim and client side. The stack function on client side adds pan/zoom as ref if no ref specified, and then forwards command to sim - This also makes these commands (MCRE / WPT) more suitable to script in a - scenario file - - PAN/ZOOM as scenario init commands in SCN file. - => Store client-stack commands for late client joiners? - => OR: copy entire scenario stack on join / SCN load. Can also be used to show stack in UI +- PAN/ZOOM as scenario init commands in SCN file. +=> Store client-stack commands for late client joiners? +=> OR: copy entire scenario stack on join / SCN load. Can also be used to show stack in UI ## Common/plugin - auto separation margin by sector - time-based separation / RECAT WTC diff --git a/bluesky/__init__.py b/bluesky/__init__.py index e75b34a90d..9816a38067 100644 --- a/bluesky/__init__.py +++ b/bluesky/__init__.py @@ -20,6 +20,7 @@ gui = '' # Main singleton objects in BlueSky +ref = None net = None traf = None navdb = None @@ -77,6 +78,10 @@ def init(mode='sim', configfile=None, scenfile=None, discoverable=False, from bluesky import tools tools.init() + # Initialise reference data object + from bluesky import refdata + globals()['ref'] = refdata.RefData() + # Load navdatabase in all versions of BlueSky # Only the headless server doesn't need this if mode == "sim" or gui is not None: diff --git a/bluesky/navdatabase/navdatabase.py b/bluesky/navdatabase/navdatabase.py index aba496f24e..016e92be19 100644 --- a/bluesky/navdatabase/navdatabase.py +++ b/bluesky/navdatabase/navdatabase.py @@ -96,12 +96,11 @@ def reset(self): self.rwythresholds = rwythresholds def defwpt(self,name=None,lat=None,lon=None,wptype=None): - # Prevent polluting the database: check arguments - if name==None or name=="": - return False,"Insufficient arguments" + if name is None or name == "": + return False, "Insufficient arguments" elif name.isdigit(): - return False,"Name needs to start with an alphabetical character" + return False, "Name needs to start with an alphabetical character" # DEL command: give info on waypoint (shudl work wit or without lat,lon, may be clicked by accident elif (not wptype==None and (wptype.upper()=="DEL" or wptype.upper() =="DELETE")) or \ @@ -111,17 +110,16 @@ def defwpt(self,name=None,lat=None,lon=None,wptype=None): # No data: give info on waypoint elif lat==None or lon==None: - reflat, reflon = bs.scr.getviewctr() if self.wpid.count(name.upper()) > 0: - i = self.getwpidx(name.upper(),reflat,reflon) - txt = self.wpid[i]+" : "+str(self.wplat[i])+","+str(self.wplon[i]) - if len(self.wptype[i])>0: - txt = txt+" "+self.wptype[i] - return True,txt + i = self.getwpidx(name.upper(), bs.ref.lat, bs.ref.lon) + txt = f"{self.wpid[i]} : {self.wplat[i]}, {self.wplon[i]}" + if len(self.wptype[i]) > 0: + txt = txt + " " + self.wptype[i] + return True, txt # Waypoint name is free else: - return True,"Waypoint "+name.upper()+" does not yet exist." + return True, f"Waypoint {name.upper()} does not yet exist." # Still here? So there is data, then we add this waypoint self.wpid.append(name.upper()) @@ -143,12 +141,12 @@ def defwpt(self,name=None,lat=None,lon=None,wptype=None): return True #,name.upper()+" added to navdb." - def delwpt(self,name=None): + def delwpt(self,name=''): """ Delete a waypoint""" if self.wpid.count(name.upper()) <= 0: - return False,"Waypoint "+name.upper()+" does not exist." + return False,"Waypoint " + name.upper() + " does not exist." - idx = len(self.wpid)-self.wpid[::-1].index(name)-1 # Search from back of list + idx = len(self.wpid) - self.wpid[::-1].index(name) - 1 # Search from back of list del self.wpid[idx] # wp name @@ -161,12 +159,12 @@ def delwpt(self,name=None): del self.wpfreq[idx] # frequency [kHz/MHz] del self.wpdesc[idx] # description - # Update screen info 9delete necessary there?) + # Update screen info (delete necessary there?) bs.scr.removenavwpt(name.upper()) return True #,name.upper()+" deleted from navdb." - def getwpidx(self, txt, reflat=999999., reflon=999999): + def getwpidx(self, txt, reflat: float|None = None, reflon: float|None = None): """Get waypoint index to access data""" name = txt.upper() try: @@ -175,7 +173,7 @@ def getwpidx(self, txt, reflat=999999., reflon=999999): return -1 # if no pos is specified, get first occurence - if not reflat < 99999.: + if reflat is None: return i # If pos is specified check for more and return closest @@ -201,7 +199,7 @@ def getwpidx(self, txt, reflat=999999., reflon=999999): dmin = d return imin - def getwpindices(self, txt, reflat=999999., reflon=999999,crit=1852.0): + def getwpindices(self, txt, reflat: float|None = None, reflon: float|None = None, crit=1852.0): """Get waypoint index to access data""" name = txt.upper() try: @@ -210,7 +208,7 @@ def getwpindices(self, txt, reflat=999999., reflon=999999,crit=1852.0): return [-1] # if no pos is specified, get first occurence - if not reflat < 99999.: + if reflat is None: return [i] # If pos is specified check for more and return closest diff --git a/bluesky/refdata.py b/bluesky/refdata.py new file mode 100644 index 0000000000..104ccaec61 --- /dev/null +++ b/bluesky/refdata.py @@ -0,0 +1,48 @@ +''' Reference values for simulation data. ''' +from bluesky.core.base import Base +from bluesky import stack +from bluesky.tools import areafilter + + +class RefData(Base): + def __init__(self) -> None: + super().__init__() + self.lat: float = 0.0 + self.lon: float = 0.0 + self.alt: float = 0.0 + self.acidx: int = -1 + self.hdg: float = 0.0 + self.cas: float = 0.0 + self.area: areafilter.Shape = areafilter.Box('refarea', (-1.0, -1.0, 1.0, 1.0)) + + def reset(self): + ''' Reset reference data. ''' + self.lat = 0.0 + self.lon = 0.0 + self.alt = 0.0 + self.acidx = -1 + self.hdg = 0.0 + self.cas = 0.0 + self.area = areafilter.Box('refarea', (-1.0, -1.0, 1.0, 1.0)) + + @stack.command + def near(self, lat: 'lat', lon: 'lon', cmdstr: 'string'): + '''Set reference lat/lon before executing command string. ''' + self.lat = lat + self.lon = lon + stack.process(cmdstr) + + @stack.commandgroup + def inside(self, lat0: 'lat', lon0: 'lon', lat1: 'lat', lon1: 'lon', cmdstr: 'string'): + self.area = areafilter.Box('refarea', (lat0, lon0, lat1, lon1)) + stack.process(cmdstr) + + @inside.altcommand + def insidearea(self, areaname: 'txt', cmdstr: 'string'): + self.area = areafilter.getArea(areaname) + stack.process(cmdstr) + + @stack.command(aliases=('with',)) + def withac(self, idx: 'acid', cmdstr: 'string'): + self.acidx = idx + stack.process(cmdstr) diff --git a/bluesky/simulation/screenio.py b/bluesky/simulation/screenio.py index 40c2661c6d..3e284fd54b 100644 --- a/bluesky/simulation/screenio.py +++ b/bluesky/simulation/screenio.py @@ -81,22 +81,6 @@ def echo(self, text='', flags=0): def cmdline(self, text): bs.net.send(b'CMDLINE', text) - def getviewctr(self): - return self.client_pan.get(stack.sender()) or self.def_pan - - def getviewbounds(self): - # Get appropriate lat/lon/zoom/aspect ratio - sender = stack.sender() - lat, lon = self.client_pan.get(sender) or self.def_pan - zoom = self.client_zoom.get(sender) or self.def_zoom - ar = self.client_ar.get(sender) or 1.0 - - lat0 = lat - 1.0 / (zoom * ar) - lat1 = lat + 1.0 / (zoom * ar) - lon0 = lon - 1.0 / (zoom * np.cos(np.radians(lat))) - lon1 = lon + 1.0 / (zoom * np.cos(np.radians(lat))) - return lat0, lat1, lon0, lon1 - def showroute(self, acid): ''' Toggle show route for this aircraft ''' if not stack.sender(): diff --git a/bluesky/simulation/simulation.py b/bluesky/simulation/simulation.py index 2762062946..a52a214c3b 100644 --- a/bluesky/simulation/simulation.py +++ b/bluesky/simulation/simulation.py @@ -201,6 +201,7 @@ def reset(self): self.set_dtmult(1.0) hooks.reset.trigger() core.reset() + bs.ref.reset() bs.navdb.reset() bs.traf.reset() simstack.reset() diff --git a/bluesky/stack/argparser.py b/bluesky/stack/argparser.py index bf6fd179cd..8e8aa3b568 100644 --- a/bluesky/stack/argparser.py +++ b/bluesky/stack/argparser.py @@ -1,7 +1,6 @@ ''' Stack argument parsers. ''' import inspect import re -from types import SimpleNamespace from matplotlib import colors from bluesky.tools.misc import txt2bool, txt2lat, txt2lon, txt2alt, txt2tim, \ txt2hdg, txt2vs, txt2spd @@ -19,25 +18,12 @@ r'\s*[\'"]?((?<=[\'"])[^\'"]*|(?= 0 and wpname in bs.traf.ap.route[refdata.acidx].wpname or wpname == '*': + if bs.ref.acidx >= 0 and wpname in bs.traf.ap.route[bs.ref.acidx].wpname or wpname == '*': return wpname, argstring - raise ArgumentError(f'{wpname} not found in the route of {bs.traf.id[refdata.acidx]}') + raise ArgumentError(f'{wpname} not found in the route of {bs.traf.id[bs.ref.acidx]}') class WptArg(Parser): ''' Argument parser for waypoints. @@ -229,8 +215,8 @@ def parse(self, argstring): # Check if lat/lon combination if islat(argu): nextarg, argstring = re_getarg.match(argstring).groups() - refdata.lat = txt2lat(argu) - refdata.lon = txt2lon(nextarg) + bs.ref.lat = txt2lat(argu) + bs.ref.lon = txt2lon(nextarg) return txt2lat(argu), txt2lon(nextarg), argstring # apt,runway ? Combine into one string with a slash as separator @@ -238,17 +224,14 @@ def parse(self, argstring): arg, argstring = re_getarg.match(argstring).groups() argu = argu + "/" + arg.upper() - if refdata.lat is None: - refdata.lat, refdata.lon = bs.scr.getviewctr() - - posobj = Position(argu, refdata.lat, refdata.lon) + posobj = Position(argu, bs.ref.lat, bs.ref.lon) if posobj.error: raise ArgumentError(f'{argu} is not a valid waypoint, airport, runway, or aircraft id.') # Update reference lat/lon - refdata.lat = posobj.lat - refdata.lon = posobj.lon - refdata.hdg = posobj.refhdg + bs.ref.lat = posobj.lat + bs.ref.lon = posobj.lon + bs.ref.hdg = posobj.refhdg return posobj.lat, posobj.lon, argstring @@ -301,7 +284,7 @@ def parse(self, argstring): 'spd': Parser(txt2spd), 'vspd': Parser(txt2vs), 'alt': Parser(txt2alt), - 'hdg': Parser(lambda txt: txt2hdg(txt, refdata.lat, refdata.lon)), + 'hdg': Parser(lambda txt: txt2hdg(txt, bs.ref.lat, bs.ref.lon)), 'time': Parser(txt2tim), 'colour': ColorArg(), 'color': ColorArg(), diff --git a/bluesky/tools/areafilter.py b/bluesky/tools/areafilter.py index 5b6a84b630..9b2b273d72 100644 --- a/bluesky/tools/areafilter.py +++ b/bluesky/tools/areafilter.py @@ -59,6 +59,11 @@ def hasArea(areaname): return areaname in basic_shapes +def getArea(areaname): + ''' Return the area object corresponding to name ''' + return basic_shapes.get(areaname, None) + + def defineArea(name, shape, coordinates, top=1e9, bottom=-1e9): """Define a new area""" if name == 'LIST': diff --git a/bluesky/traffic/traffic.py b/bluesky/traffic/traffic.py index d61fcfbf68..0cbbcb0273 100644 --- a/bluesky/traffic/traffic.py +++ b/bluesky/traffic/traffic.py @@ -6,7 +6,6 @@ import bluesky as bs from bluesky.core import Entity, Timer -from bluesky.stack import refdata from bluesky.stack.recorder import savecmd from bluesky.tools import geo from bluesky.tools.misc import latlon2txt @@ -184,15 +183,15 @@ def reset(self): def mcre(self, n, actype="B744", acalt=None, acspd=None, dest=None): """ Create one or more random aircraft in a specified area """ - area = bs.scr.getviewbounds() + area = bs.ref.area.bbox # Generate random callsigns idtmp = chr(randint(65, 90)) + chr(randint(65, 90)) + '{:>05}' acid = [idtmp.format(i) for i in range(n)] # Generate random positions - aclat = np.random.rand(n) * (area[1] - area[0]) + area[0] - aclon = np.random.rand(n) * (area[3] - area[2]) + area[2] + aclat = np.random.rand(n) * (area[2] - area[0]) + area[0] + aclon = np.random.rand(n) * (area[3] - area[1]) + area[1] achdg = np.random.randint(1, 360, n) acalt = acalt or np.random.randint(2000, 39000, n) * ft acspd = acspd or np.random.randint(250, 450, n) * kts @@ -228,7 +227,7 @@ def cre(self, acid, actype="B744", aclat=52., aclon=4., achdg=None, acalt=0, acs aclon[aclon > 180.0] -= 360.0 aclon[aclon < -180.0] += 360.0 - achdg = (refdata.hdg or 0.0) if achdg is None else achdg + achdg = (bs.ref.hdg or 0.0) if achdg is None else achdg # Aircraft Info self.id[-n:] = acid @@ -554,66 +553,11 @@ def move(self, idx, lat, lon, alt=None, hdg=None, casmach=None, vspd=None): self.vs[idx] = vspd self.swvnav[idx] = False - def poscommand(self, idxorwp):# Show info on aircraft(int) or waypoint or airport (str) + def poscommand(self, idxorwp: int|str): """POS command: Show info or an aircraft, airport, waypoint or navaid""" - # Aircraft index - - if type(idxorwp)==int and idxorwp >= 0: - - idx = idxorwp - acid = self.id[idx] - actype = self.type[idx] - latlon = latlon2txt(self.lat[idx], self.lon[idx]) - alt = round(self.alt[idx] / ft) - hdg = round(self.hdg[idx]) - trk = round(self.trk[idx]) - cas = round(self.cas[idx] / kts) - tas = round(self.tas[idx] / kts) - gs = round(self.gs[idx]/kts) - M = self.M[idx] - VS = round(self.vs[idx]/ft*60.) - route = self.ap.route[idx] - - # Position report - lines = "Info on %s %s index = %d\n" %(acid, actype, idx) \ - + "Pos: "+latlon+ "\n" \ - + "Hdg: %03d Trk: %03d\n" %(hdg, trk) \ - + "Alt: %d ft V/S: %d fpm\n" %(alt,VS) \ - + "CAS/TAS/GS: %d/%d/%d kts M: %.3f\n"%(cas,tas,gs,M) - - # FMS AP modes - if self.swlnav[idx] and route.nwp > 0 and route.iactwp >= 0: - - if self.swvnav[idx]: - if self.swvnavspd[idx]: - lines = lines + "VNAV (incl.VNAVSPD), " - else: - lines = lines + "VNAV (NOT VNAVSPD), " - - lines += "LNAV to " + route.wpname[route.iactwp] + "\n" - - # Flight info: Destination and origin - if self.ap.orig[idx] != "" or self.ap.dest[idx] != "": - lines = lines + "Flying" - - if self.ap.orig[idx] != "": - lines = lines + " from " + self.ap.orig[idx] - - if self.ap.dest[idx] != "": - lines = lines + " to " + self.ap.dest[idx] - - # Show a/c info and highlight route of aircraft in radar window - # and pan to a/c (to show route) - bs.scr.showroute(acid) - return True, lines - - # Waypoint: airport, navaid or fix - else: + if isinstance(idxorwp, str): + # Argument is a waypoint: airport, navaid or fix wp = idxorwp.upper() - - # Reference position for finding nearest - reflat, reflon = bs.scr.getviewctr() - lines = "Info on "+wp+":\n" # First try airports (most used and shorter, hence faster list) @@ -646,7 +590,7 @@ def poscommand(self, idxorwp):# Show info on aircraft(int) or waypoint or airpor # Not found as airport, try waypoints & navaids else: - iwps = bs.navdb.getwpindices(wp,reflat,reflon) + iwps = bs.navdb.getwpindices(wp,bs.ref.lat,bs.ref.lon) if iwps[0]>=0: typetxt = "" desctxt = "" @@ -723,19 +667,65 @@ def poscommand(self, idxorwp):# Show info on aircraft(int) or waypoint or airpor else: return False,idxorwp+" not found as a/c, airport, navaid or waypoint" - # Show what we found on airport and navaid/waypoint - return True, lines + elif idxorwp >= 0: + # Argument is an aircraft id + idx = idxorwp + acid = self.id[idx] + actype = self.type[idx] + latlon = latlon2txt(self.lat[idx], self.lon[idx]) + alt = round(self.alt[idx] / ft) + hdg = round(self.hdg[idx]) + trk = round(self.trk[idx]) + cas = round(self.cas[idx] / kts) + tas = round(self.tas[idx] / kts) + gs = round(self.gs[idx]/kts) + M = self.M[idx] + VS = round(self.vs[idx]/ft*60.) + route = self.ap.route[idx] + + # Position report + lines = "Info on %s %s index = %d\n" %(acid, actype, idx) \ + + "Pos: "+latlon+ "\n" \ + + "Hdg: %03d Trk: %03d\n" %(hdg, trk) \ + + "Alt: %d ft V/S: %d fpm\n" %(alt,VS) \ + + "CAS/TAS/GS: %d/%d/%d kts M: %.3f\n"%(cas,tas,gs,M) + + # FMS AP modes + if self.swlnav[idx] and route.nwp > 0 and route.iactwp >= 0: + + if self.swvnav[idx]: + if self.swvnavspd[idx]: + lines = lines + "VNAV (incl.VNAVSPD), " + else: + lines = lines + "VNAV (NOT VNAVSPD), " + + lines += "LNAV to " + route.wpname[route.iactwp] + "\n" + + # Flight info: Destination and origin + if self.ap.orig[idx] != "" or self.ap.dest[idx] != "": + lines = lines + "Flying" + + if self.ap.orig[idx] != "": + lines = lines + " from " + self.ap.orig[idx] + + if self.ap.dest[idx] != "": + lines = lines + " to " + self.ap.dest[idx] + + # Show a/c info and highlight route of aircraft in radar window + # and pan to a/c (to show route) + bs.scr.showroute(acid) + + # Show what we found on aircraft, airport and navaid/waypoint + return True, lines def airwaycmd(self, key): ''' Show conections of a waypoint or airway. ''' - reflat, reflon = bs.scr.getviewctr() - if bs.navdb.awid.count(key) > 0: return self.poscommand(key) # Find connecting airway legs wpid = key - iwp = bs.navdb.getwpidx(wpid,reflat,reflon) + iwp = bs.navdb.getwpidx(wpid, bs.ref.lat, bs.ref.lon) if iwp < 0: return False,key + " not found." @@ -745,9 +735,9 @@ def airwaycmd(self, key): if connect: lines = "" for c in connect: - if len(c)>=2: + if len(c) >= 2: # Add airway, direction, waypoint - lines = lines+ c[0]+": to "+c[1]+"\n" + lines = lines + c[0] + ": to " + c[1] + "\n" return True, lines[:-1] # exclude final newline return False, f"No airway legs found for {key}" diff --git a/bluesky/ui/pygame/screen.py b/bluesky/ui/pygame/screen.py index 243b842cbb..ba73d9f3c6 100644 --- a/bluesky/ui/pygame/screen.py +++ b/bluesky/ui/pygame/screen.py @@ -1042,6 +1042,10 @@ def pan(self, *args): self.satsel = () self.geosel = () + # Use new centre lat/lon to update reference position + bs.ref.lat = self.ctrlat + bs.ref.lon = self.ctrlon + return True def fullscreen(self, switch): # full screen switch diff --git a/bluesky/ui/qtgl/mainwindow.py b/bluesky/ui/qtgl/mainwindow.py index 464c517da8..4a8dc5a34a 100644 --- a/bluesky/ui/qtgl/mainwindow.py +++ b/bluesky/ui/qtgl/mainwindow.py @@ -227,6 +227,18 @@ def setStyleSheet(self, contents=''): with open(bs.resource(bs.settings.gfx_path) / 'bluesky.qss') as style: super().setStyleSheet(style.read()) + @stack.command + def mcre(self, args: 'string'): + """ Create one or more random aircraft in a specified area + + When called from the client (gui), MCRE will use the current screen bounds as area to create aircraft in. + When called from a scenario, the simulation reference area will be used. + """ + if not args: + return stack.forward('MCRE') + + stack.forward(f'INSIDE {" ".join(str(el) for el in bs.ref.area.bbox)} MCRE {args}') + @stack.command(annotations='pandir/latlon', brief='PAN latlon/acid/airport/waypoint/LEFT/RIGHT/UP/DOWN') def pan(self, *args): "Pan screen (move view) to a waypoint, direction or aircraft" diff --git a/bluesky/ui/qtgl/radarwidget.py b/bluesky/ui/qtgl/radarwidget.py index 3122ccdafc..d785b14fbd 100644 --- a/bluesky/ui/qtgl/radarwidget.py +++ b/bluesky/ui/qtgl/radarwidget.py @@ -2,6 +2,8 @@ from ctypes import c_float, c_int, Structure import numpy as np +from bluesky.tools import areafilter + try: from PyQt5.QtCore import Qt, QEvent, QT_VERSION except ImportError: @@ -225,6 +227,11 @@ def on_panzoom(self, data=None, finished=True): np.floor(-360.0 + self.pan[1] + 1.0 / (self.zoom * self.flat_earth))) self.wrapdir = 1 + # Use new centre lat/lon to update reference position + bs.ref.lat = self.pan[0] + bs.ref.lon = self.pan[1] + bs.ref.area = areafilter.Box('refarea', self.viewportlatlon()) + # update pan and zoom on GPU for all shaders self.shaderset.set_wrap(self.wraplon, self.wrapdir) self.shaderset.set_pan_and_zoom(self.pan[0], self.pan[1], self.zoom)