Source code for ggame.astro

"""
ggame extensions for modeling spacecraft in planetary orbit
"""

from math import pi, degrees, radians, atan2, sin, cos, sqrt
from ggame.asset import LineStyle, Color
from ggame.mathapp import MathApp
from ggame.circle import Circle
from ggame.point import ImagePoint
from ggame.timer import Timer
from ggame.label import Label


[docs] class Planet(MathApp): """ Initialize the Planet object. Optional keyword parameters are supported: :param float viewscale: Pixels per meter in graphics display. Default is 10. :param float radius: Radius of the planet in meters. Default is Earth radius. :param float planetmass: Mass of the planet in kg. Default is Earth mass. :param int color: Color of the planet. Default is greenish (0x008040). :param float viewalt: Altitude of initial viewpoint in meters. Default is rocket altitude. :param float viewanom: True anomaly (angle) of initial viewpoint in radians. Default is the rocket anomaly. :param float viewanomd: True anomaly (angle) of initial viewpoing in degrees. Default is the rocket anomaly. Example: .. literalinclude:: ../examples/astroplanet.py """ def __init__(self, **kwargs): scale = kwargs.get("viewscale", 10) # 10 pixels per meter default self.radius = kwargs.get("radius", 6.371e6) # Earth - meters self.mass = kwargs.get("planetmass", 5.9722e24) # Earth - kg self.color = kwargs.get("color", 0x008040) # greenish self.viewaltitude = kwargs.get("viewalt", 0) # how high to look self.viewanomaly = kwargs.get("viewanom", pi / 2) # where to look self.viewanomalyd = kwargs.get("viewanomd", degrees(self.viewanomaly)) self._planetcircle = Circle( (0, 0), self.radius, style=LineStyle(1, Color(self.color, 1)), color=Color(self.color, 0.5), ) super().__init__(scale)
[docs] def run(self, userfunc=None): """ Execute the Planet (and Rocket) simulation without setting the initial view. """ self.runWithRocket()
[docs] def runWithRocket(self, rocket=None): """ Execute the Planet (and Rocket) simulation. :Optional parameters: :param Rocket rocket: Reference to a Rocket object - sets the initial view :returns: None """ if rocket: self.viewaltitude = rocket.altitude self.viewanomaly = rocket.tanomaly r = self.radius + self.viewaltitude self.view_position = (r * cos(self.viewanomaly), r * sin(self.viewanomaly)) super().run()
[docs] class Rocket(ImagePoint): """ Rocket is a class for simulating the motion of a projectile through space, acted upon by arbitrary forces (thrust) and by gravitaitonal attraction to a single planetary object. Required parameters: :param Planet planet: Reference to a :class:`Planet` object. Optional keyword parameters are supported: :param str bitmap: Url of a suitable bitmap image for the rocket (png recommended), default is `images/rocket.png` :param float bitmapscale: Scale factor for bitmap. Default is 0.1 :param float velocity: Initial rocket speed. Default is zero. :param float directiond: Initial rocket direction in degrees. Default is zero. :param float direction: Initial rocket direction in radians. Default is zero. :param float tanomalyd: Initial rocket true anomaly in degrees. Default is 90. :param float tanomaly: Initial rocket true anomaly in radians. Default is pi/2. :param float altitude: Initial rocket altitude in meters. Default is zero. :param bool showstatus: Boolean displays flight parameters on screen. Default is True. :param (int, int) statuspos: Tuple with screen x,y coordinates of flight parameters. Default is upper left. :param list[str] statuslist: List of status names to include in flight parameters. Default is all, consisting of: "velocity", "acceleration", "course", "altitude", "thrust", "mass", "trueanomaly", "scale", "timezoom", "shiptime" :param str leftkey: A :class:`ggame.event.KeyEvent` key identifier that will serve as the "rotate left" key while controlling the ship. Default is 'left arrow'. :param str rightkey: A :class:`ggame.event.KeyEvent` key identifier that will serve as the "rotate right" key while controlling the ship. Default is 'right arrow'. Following parameters may be set as a constant value, or pass in the name of a function that will return the value dynamically or the name of a `ggmath` UI control that will return the value. :param function or float timezoom: Scale factor for time zoom. Factor = 10^timezoom :param function or float heading: Direction to point the rocket in (must be radians) :param fucntion or float mass: Mass of the rocket (must be kg) :param function or float thrust: Thrust of the rocket (must be N) Animation related parameters may be ignored if no sprite animation: :param Frame bitmapframe: ((x1,y1),(x2,y2)) tuple defines a region in the bitmap :param int bitmapqty: Number of bitmaps -- used for animation effects :param str bitmapdir: "horizontal" or "vertical" use with animation effects. Default is "horizontal" :param int bitmapmargin: Pixels between successive animation frames :param float tickrate: Frequency of spacecraft dynamics calculations (Hz) Example: .. literalinclude:: ../examples/astrorocket.py """ def __init__(self, planet, **kwargs): self._xy = (1000000, 1000000) self.planet = planet """Reference to an app object of :class:`Planet` class""" self._bmurl = kwargs.get("bitmap", self.getImagePath("rocket.png")) """URL of a bitmap image to use for the rocket""" self._bitmapframe = kwargs.get("bitmapframe", None) """:class:`ggame.asset.Frame` that specifies location of displayed image in bitmap image """ self._bitmapqty = kwargs.get("bitmapqty", 1) """Number of sub-images in the bitmap image""" self._bitmapdir = kwargs.get("bitmapdir", "horizontal") """Orientation of the sub-images for animation (one of "horizontal" or "vertical") """ self._bitmapmargin = kwargs.get("bitmapmargin", 0) """Margin between the sub-images for animation.""" self.tickrate = kwargs.get("tickrate", 30) """Target animation frames per second.""" # status display statusfuncs = [ self._velocityText, self._accelerationText, self._courseDegreesText, self._altitudeText, self._thrustText, self._massText, self._trueAnomalyDegreesText, self._scaleText, self._timeZoomText, self._shipTimeText, ] statuslist = [ "velocity", "acceleration", "course", "altitude", "thrust", "mass", "trueanomaly", "scale", "timezoom", "shiptime", ] self._showstatus = kwargs.get("showstatus", True) """Boolean: True to show stats on screen, else False.""" self._statuspos = kwargs.get("statuspos", [10, 10]) """Location on screen for showing the stats.""" self._statusselect = kwargs.get("statuslist", statuslist) """Reference to a list of statuses to display.""" self.localheading = 0 """Heading (angle) of travel""" # dynamic parameters self.timezoom = self.eval(kwargs.get("timezoom", 0)) """Reference to a function that will dynamically determine the time zoom factor. An integer: 1,2,3 faster, -1, slower. """ self.heading = self.eval(kwargs.get("heading", self._getheading)) """Reference to a function that will dynamically calculate spaceship orientation or heading (in radians) """ self.mass = self.eval(kwargs.get("mass", 1)) """Reference to a function that will dynamically calculate the spaceship mass (in kg). """ self.thrust = self.eval(kwargs.get("thrust", 0)) """Reference to a funtion that will dynamically calculate the rocket thrust (in N). """ # end dynamic super().__init__( self._bmurl, self._getposition, frame=self._bitmapframe, qty=self._bitmapqty, direction=self._bitmapdir, margin=self._bitmapmargin, ) self.scale = kwargs.get("bitmapscale", 0.1) # small initvel = kwargs.get("velocity", 0) # initial velocity initdird = kwargs.get("directiond", 0) # initial direction, degrees initdir = kwargs.get("direction", radians(initdird)) tanomaly = kwargs.get("tanomaly", pi / 2) # position angle tanomaly = radians(kwargs.get("tanomalyd", degrees(tanomaly))) altitude = kwargs.get("altitude", 0) # r = altitude + self.planet.radius self._xy = (r * cos(tanomaly), r * sin(tanomaly)) # default heading control if none provided by user leftkey = kwargs.get("leftkey", "left arrow") rightkey = kwargs.get("rightkey", "right arrow") if self.heading == self._getheading: # pylint: disable=comparison-with-callable Planet.listenKeyEvent("keydown", leftkey, self._turn) Planet.listenKeyEvent("keydown", rightkey, self._turn) self._timer = Timer() self.shiptime = 0 # track time on shipboard self._timer.callEvery(1 / self.tickrate, self._dynamics) self._lasttime = self._timer.time self._v_vect = [initvel * cos(initdir), initvel * sin(initdir)] self._a_vect = [0, 0] # keep track of on-screen resources self._labels = [] # set up status display if self._showstatus: self.addStatusReport(statuslist, statusfuncs, self._statusselect) @staticmethod def _vadd(v1, v2): """ Vector add utility. """ return [v1[i] + v2[i] for i in (0, 1)] @staticmethod def _vmul(s, v): """ Scalar vector multiplication utility. """ return [s * v[i] for i in (0, 1)] @staticmethod def _vmag(v): """ Vector magnitude function. """ return sqrt(v[0] ** 2 + v[1] ** 2) def _turn(self, event): """ Respond to left/right turning key events. """ increment = pi / 50 * (1 if event.key == "left arrow" else -1) self.localheading += increment def _getposition(self): return self._xy # override or define externally! def _getheading(self): """ User override function for dynamically determining the heading. :returns: float """ return self.localheading # functions available for reporting flight parameters to UI def _velocityText(self): """ Report the velocity in m/s as a text string. """ return f"Velocity: {self.velocity:8.1f} m/s" def _accelerationText(self): """ Report the acceleration in m/s as a text string. """ return f"Acceleration: {self.acceleration:8.1f} m/s²" def _courseDegreesText(self): """ Report the heading in degrees (zero to the right) as a text string. """ return f"Course: {degrees(atan2(self._v_vect[1], self._v_vect[0])):8.1f}°" def _thrustText(self): """ Report the thrust level in Newtons as a text string. """ return f"Thrust: {self.thrust():8.1f} N" def _massText(self): """ Report the spacecraft mass in kilograms as a text string. """ return f"Mass: {self.mass():8.1f} kg" def _trueAnomalyDegreesText(self): """ Report the true anomaly in degrees as a text string. """ return f"True Anomaly: {self.tanomalyd:8.1f}°" def _trueAnomalyRadiansText(self): """ Report the true anomaly in radians as a text string. """ return f"True Anomaly: {self.tanomaly:8.4f}" def _altitudeText(self): """ Report the altitude in meters as a text string. """ return f"Altitude: {self.altitude:8.1f} m" def _radiusText(self): """ Report the radius (distance to planet center) in meters as a text string. """ return f"Radius: {self.r:8.1f} m" def _scaleText(self): """ Report the view scale (pixels/meter) as a text string. """ return f"View Scale: {self.planet.scale:8.6f} px/m" def _timeZoomText(self): """ Report the time acceleration as a text string. """ return f"Time Zoom: {float(self.timezoom()):8.1f}" def _shipTimeText(self): """ Report the elapsed time as a text string. """ return f"Elapsed Time: {float(self.shiptime):8.1f} s" def _dynamics(self, timer): """ Perform one iteration of the simulation using runge-kutta 4th order method. """ # set time duration equal to time since last execution tick = 10 ** self.timezoom() * (timer.time - self._lasttime) self.shiptime = self.shiptime + tick self._lasttime = timer.time # 4th order runge-kutta method # (https://sites.temple.edu/math5061/files/2016/12/final_project.pdf) # and http://spiff.rit.edu/richmond/nbody/OrbitRungeKutta4.pdf (succinct, # but with a typo) self._a_vect = k1v = self._ar(self._xy) k1r = self._v_vect k2v = self._ar(self._vadd(self._xy, self._vmul(tick / 2, k1r))) k2r = self._vadd(self._v_vect, self._vmul(tick / 2, k1v)) k3v = self._ar(self._vadd(self._xy, self._vmul(tick / 2, k2r))) k3r = self._vadd(self._v_vect, self._vmul(tick / 2, k2v)) k4v = self._ar(self._vadd(self._xy, self._vmul(tick, k3r))) k4r = self._vadd(self._v_vect, self._vmul(tick, k3v)) self._v_vect = [ self._v_vect[i] + tick / 6 * (k1v[i] + 2 * k2v[i] + 2 * k3v[i] + k4v[i]) for i in (0, 1) ] self._xy = [ self._xy[i] + tick / 6 * (k1r[i] + 2 * k2r[i] + 2 * k3r[i] + k4r[i]) for i in (0, 1) ] if self.altitude < 0: self._v_vect = [0, 0] self._a_vect = [0, 0] self.altitude = 0 # generic force as a function of position def _fr(self, pos): """ Compute the net force vector on the rocket, as a function of the position vector. """ self.rotation = self.heading() t = self.thrust() g = 6.674e-11 r = Planet.distance((0, 0), pos) uvec = (-pos[0] / r, -pos[1] / r) fg = g * self.mass() * self.planet.mass / r**2 vf = [x * fg for x in uvec] return [vf[0] + t * cos(self.rotation), vf[1] + t * sin(self.rotation)] # geric acceleration as a function of position def _ar(self, pos): """ Compute the acceleration vector of the rocket, as a function of the position vector. """ m = self.mass() vf = self._fr(pos) return [vf[i] / m for i in (0, 1)] # add a status reporting function to status display
[docs] def addStatusReport(self, statuslist, statusfuncs, statusselect): """ Accept list of all status names, all status text functions, and the list of status names that have been selected for display. :param list[str] statuslist: List of status names to include in flight parameters. Default is all, consisting of: "velocity", "acceleration", "course", "altitude", "thrust", "mass", "trueanomaly", "scale", "timezoom", and "shiptime". :param list[func] statusfuncs: List of function references corresponding to statuslist parameter. :param list[str] statusselect: List of names chosen from statuslist to display. :returns: None """ statusdict = dict(zip(statuslist, statusfuncs)) for name in statusselect: if name in statusdict: self._labels.append( Label( self._statuspos[:], statusdict[name], size=15, positioning="physical", width=250, ) ) self._statuspos[1] += 25
@property def xyposition(self): """ Report the x,y tuple for logical position of the spaceship. """ return self._xy @xyposition.setter def xyposition(self, pos): self._xy = pos # self._touchAsset() @property def tanomalyd(self): """ Report/set the spaceship position as a direction relative to central body (degrees). """ return degrees(self.tanomaly) @tanomalyd.setter def tanomalyd(self, angle): self.tanomaly = radians(angle) @property def altitude(self): """ Report/set the spaceship altitude of planet surface in meters. """ alt = Planet.distance(self._xy, (0, 0)) - self.planet.radius return alt @altitude.setter def altitude(self, alt): r = alt + self.planet.radius self._xy = (r * cos(self.tanomaly), r * sin(self.tanomaly)) @property def velocity(self): """ Report the spaceship velocity scalar in m/s. """ return self._vmag(self._v_vect) @property def acceleration(self): """ Report the spaceship acceleration scalar in m/s. """ return self._vmag(self._a_vect) @property def tanomaly(self): """ Report/set the spaceship position as a direction relative to central body (radians). """ # pos = self._pos() return atan2(self._xy[1], self._xy[0]) @tanomaly.setter def tanomaly(self, angle): r = self.r self._xy = (r * cos(angle), r * sin(angle)) self.touchAsset() @property def r(self): """ Report the spaceship distance (radius) from central body center of mass. """ return self.altitude + self.planet.radius