def test_entity_view(self): """Test that setting and getting _EntityView attrs propagate.""" ps = PhysicsState(None, self.proto_state) self.assertEqual(ps[0].name, 'First') entity = ps[0] self.assertTrue(isinstance(entity, _EntityView)) self.assertEqual(entity.x, 10) self.assertEqual(entity.y, 20) self.assertEqual(entity.vx, 30) self.assertEqual(entity.vy, 40) self.assertEqual(entity.spin, 50) self.assertEqual(entity.fuel, 60) self.assertEqual(entity.landed_on, '') self.assertEqual(entity.throttle, 70) ps.y0() self.assertEqual(entity.heading, 7 % (2 * np.pi)) ps[0].landed_on = 'Second' self.assertEqual(entity.landed_on, 'Second') entity.x = 500 self.assertEqual(ps[0].x, 500) entity.pos = np.array([55, 66]) self.assertEqual(ps['First'].x, 55) self.assertEqual(ps['First'].y, 66)
def __call__(self, t, y_1d) -> float: """Return 0 when the craft is landed but thrusting enough to lift off, and a positive value otherwise.""" y = PhysicsState(y_1d, self.initial_state._proto_state) if y.craft is None: # There is no craft, return early. return np.inf craft = y.craft_entity() if not craft.landed(): # We don't have to lift off because we already lifted off. return np.inf planet = y[craft.landed_on] if planet.artificial: # If we're docked with another satellite, undocking is governed # by other mechanisms. Ignore this. return np.inf thrust = common.craft_capabilities[craft.name].thrust * craft.throttle if y.srb_time > 0 and y.craft == HABITAT: thrust += common.SRB_THRUST pos = craft.pos - planet.pos # This is the G(m1*m2)/r^2 formula weight = common.G * craft.mass * planet.mass / np.inner(pos, pos) # This should be positive when the craft isn't thrusting enough, and # zero when it is thrusting enough. return max(0, common.LAUNCH_TWR - thrust / weight)
def __init__(self, draw_state: PhysicsState, *, title: str, running_as_mirror: bool) -> None: assert len(draw_state) >= 1 self._state = draw_state # create a vpython canvas, onto which all 3D and HTML drawing happens. self._scene = self._init_canvas(title) self._show_label: bool = True self._show_trails: bool = DEFAULT_TRAILS self._paused: bool = False self._removed_saveload_hint: bool = False self._origin: Entity self.texture_path: Path = Path('data', 'textures') self._commands: List[Request] = [] self._paused_label = vpython.label( text="Simulation paused; saving and loading enabled.\n" "When finished, unpause by setting a time acceleration.", xoffset=0, yoffset=200, line=False, height=25, border=20, opacity=1, visible=False) self._3dobjs: Dict[str, ThreeDeeObj] = {} # remove vpython ambient lighting self._scene.lights = [] # This line shouldn't be removed self._set_origin(common.DEFAULT_CENTRE) for entity in draw_state: self._3dobjs[entity.name] = self._build_threedeeobj(entity) self._orbit_projection = OrbitProjection() self._3dobjs[draw_state.reference].draw_landing_graphic( draw_state.reference_entity()) self._3dobjs[draw_state.target].draw_landing_graphic( draw_state.target_entity()) self._sidebar = Sidebar(self, running_as_mirror) self._scene.bind('keydown', self._handle_keydown) # Add an animation when launching the program to describe the solar # system and the current location while self._scene.range > 600000: vpython.rate(100) self._scene.range = self._scene.range * 0.92 self.recentre_camera(common.DEFAULT_CENTRE)
def _update_obj(self, entity: Entity, state: PhysicsState, origin: Entity) -> None: # update planet objects self._obj.pos = entity.screen_pos(origin) self._obj.axis = calc.angle_to_vpy(entity.heading) # update label objects self._label.text = self._label_text(entity) self._label.pos = entity.screen_pos(origin) # update landing graphic objects self._update_landing_graphic(self._small_landing_graphic, entity, state.craft_entity()) self._update_landing_graphic(self._large_landing_graphic, entity, state.craft_entity())
def update(self, state: PhysicsState, origin: Entity): if not self._visible: self._hyperbola.visible = False self._ring.visible = False return orb_params = calc.orbit_parameters(state.reference_entity(), state.craft_entity()) thickness = min( self.PROJECTION_THICKNESS * vpython.canvas.get_selected().range, abs(0.1 * max(orb_params.major_axis, orb_params.minor_axis))) pos = orb_params.centre - origin.pos direction = vpython.vector(*orb_params.eccentricity, 0) e_mag = calc.fastnorm(orb_params.eccentricity) if e_mag < 1: # Project an elliptical orbit self._hyperbola.visible = False self._ring.pos = vpython.vector(*pos, 0) # size.x is actually the thickness, confusingly. # size.y and size.z are the width and length of the ellipse. self._ring.size.y = orb_params.major_axis self._ring.size.z = orb_params.minor_axis self._ring.up = direction self._ring.size.x = thickness self._ring.visible = True elif e_mag > 1: # Project a hyperbolic orbit self._ring.visible = False self._hyperbola.origin = vpython.vector(*pos, 0) # For a vpython.curve object, setting the axis is confusing. # To control direction, set the .up attribute (magnitude doesn't # matter for .up) # To control size, set the .size attribute (magnitude matters). # Setting .axis will also change .size, not sure how. self._hyperbola.size = vpython.vector(orb_params.minor_axis / 2, orb_params.major_axis / 2, 1) self._hyperbola.up = -direction self._hyperbola.radius = thickness self._hyperbola.visible = True assert self._hyperbola.axis.z == 0, self._hyperbola.axis assert self._hyperbola.up.z == 0, self._hyperbola.up else: # e == 1 pretty much never happens, and if it does we'll just wait # until it goes away return
def _reconcile_entity_dynamics(y: PhysicsState) -> PhysicsState: """Idempotent helper that sets velocities and spins of some entities. This is in its own function because it has a couple calling points.""" # Navmode auto-rotation if y.navmode != Navmode['Manual']: craft = y.craft_entity() craft.spin = calc.navmode_spin(y) # Keep landed entities glued together landed_on = y.LandedOn for index in landed_on: # If we're landed on something, make sure we move in lockstep. lander = y[index] ground = y[landed_on[index]] if ground.name == AYSE and lander.name == HABITAT: # Always put the Habitat at the docking port. lander.pos = ( ground.pos - calc.heading_vector(ground.heading) * (lander.r + ground.r)) else: norm = lander.pos - ground.pos unit_norm = norm / calc.fastnorm(norm) lander.pos = ground.pos + unit_norm * (ground.r + lander.r) lander.spin = ground.spin lander.v = calc.rotational_speed(lander, ground) return y
def relevant_atmosphere(flight_state: PhysicsState) \ -> Optional[Entity]: """Returns the closest entity that has an atmosphere, or None if there are no such entities close enough to effect the craft.""" craft = flight_state.craft_entity() closest_atmosphere: Optional[Entity] = None closest_distance = np.inf atmosphere_indices = flight_state.Atmospheres if not atmosphere_indices: # There are no entities with atmospheres return None for index in atmosphere_indices: atmosphere = flight_state[index] dist = fastnorm(atmosphere.pos - craft.pos) # np.inner is the same as the magnitude squared # https://stackoverflow.com/a/35213951 # We use the squared distance because it's much faster to calculate. if dist < closest_distance: # We found a closer planet with an atmosphere exponential = (-(dist - craft.r - atmosphere.r) / 1000 / atmosphere.atmosphere_scaling) if exponential > -20: # Entity has an atmosphere and it's close enough to be relevant closest_distance = dist closest_atmosphere = atmosphere return closest_atmosphere
def handle_requests(self, requests: List[Request], requested_t=None): requested_t = self._simtime(requested_t) if len(requests) == 0: return if len(requests) and requests[0].ident == Request.TIME_ACC_SET: # Immediately change the time acceleration, don't wait for the # simulation to catch up. This deals with the case where we're at # 100,000x time acc, and the program seems frozen for the user and # they try lowering time acc. We should immediately be able to # restart simulation at a lower time acc without any waiting. if len(self._solutions) == 0: # We haven't even simulated any solutions yet. requested_t = self._last_physical_state.timestamp else: requested_t = min(self._solutions[-1].t_max, requested_t) if len(self._solutions) == 0: y0 = PhysicsState(None, self._last_physical_state) else: y0 = self.get_state(requested_t) for request in requests: if request.ident == Request.NOOP: # We don't care about these requests continue y0 = _one_request(request, y0) if request.ident == Request.TIME_ACC_SET: assert request.time_acc_set >= 0 self._time_acc_changes.append( TimeAccChange(time_acc=y0.time_acc, start_simtime=y0.timestamp) ) self.set_state(y0)
def test_gravitation(self): """Test that gravitational acceleration at t=0 is as expected.""" with PhysicsEngine('tests/massive-objects.json') as physics_engine: # In this case, the first entity is very massive and the second # entity should gravitate towards the first entity. initial = physics_engine.get_state(0) moved = physics_engine.get_state(1) # https://www.wolframalpha.com/input/?i=1e30+kg+*+G+%2F+(1e8+m)%5E2 # According to the above, this should be somewhere between 6500 and # 7000 m/s after one second. self.assertTrue(moved[1].vx > 5000, msg=f'vx is actually {moved[1].vx}') # Test the internal math that the internal derive function is doing # the right calculations. Break out your SPH4U physics equations!! y0 = initial # Note that dy.X is actually the velocity at 0, # and dy.VX is acceleration. dy = PhysicsState( physics_engine._derive(0, y0.y0(), y0._proto_state), y0._proto_state) self.assertEqual(len(dy.X), 2) self.assertAlmostEqual(dy.X[0], y0.VX[0]) self.assertAlmostEqual(dy.Y[0], y0.VY[0]) self.assertEqual( round(abs(dy.VX[0])), round(common.G * initial[1].mass / (y0.X[0] - y0.X[1])**2)) self.assertAlmostEqual(dy.VY[0], 0) self.assertAlmostEqual(dy.X[1], y0.VX[1]) self.assertAlmostEqual(dy.Y[1], y0.VY[1]) self.assertEqual( round(abs(dy.VX[1])), round(common.G * initial[0].mass / (y0.X[1] - y0.X[0])**2)) self.assertAlmostEqual(dy.VY[1], 0)
def test_get_set(self): """Test __getitem__ and __setitem__.""" ps = PhysicsState(None, self.proto_state) entity = ps[0] entity.landed_on = 'Second' ps[0] = entity self.assertEqual(ps[0].landed_on, 'Second')
def __call__(self, t, y_1d) -> float: """Return a 0 only when throttle is nonzero.""" y = PhysicsState(y_1d, self.initial_state._proto_state) for index, entity in enumerate(y._proto_state.entities): if entity.artificial and y.Throttle[index] != 0: return y.Fuel[index] return np.inf
def __call__(self, t, y_1d, return_pair=False ) -> Union[float, Tuple[int, int]]: """Returns a scalar, with 0 indicating a collision and a sign change indicating a collision has happened.""" y = PhysicsState(y_1d, self.initial_state._proto_state) n = len(self.initial_state) # 2xN of (x, y) positions posns = np.column_stack((y.X, y.Y)) # An n*n matrix of _altitudes_ between each entity alt_matrix = ( scipy.spatial.distance.cdist(posns, posns) - np.array([self.radii]) - np.array([self.radii]).T) # To simplify calculations, an entity's altitude from itself is inf np.fill_diagonal(alt_matrix, np.inf) # For each pair of objects that have collisions disabled between # them, also set their altitude to be inf # If there are any entities landed on any other entities, ignore # both the landed and the landee entity. landed_on = y.LandedOn for index in landed_on: alt_matrix[index, landed_on[index]] = np.inf alt_matrix[landed_on[index], index] = np.inf if return_pair: # Returns the actual pair of indicies instead of a scalar. flattened_index = alt_matrix.argmin() # flattened_index is a value in the interval [1, n*n]-1. # Turn it into a 2D index. object_i = flattened_index // n object_j = flattened_index % n return object_i, object_j else: # solve_ivp invocation, return scalar return np.min(alt_matrix)
def rotation_formatter(state: PhysicsState) -> str: deg_spin = round(np.degrees(state.craft_entity().spin), ndigits=1) if deg_spin < 0: return f"{-deg_spin} °/s cw" elif deg_spin > 0: return f"{deg_spin} °/s ccw" else: return f"{deg_spin} °/s"
def navmode_heading(flight_state: PhysicsState) -> float: """ Returns the heading that the craft should be facing in current navmode. Don't call this with Manual navmode. If the navmode is relative to the reference, and the reference is set to the craft, this will return the craft's current heading as a sane default. """ navmode = flight_state.navmode craft = flight_state.craft_entity() ref = flight_state.reference_entity() targ = flight_state.target_entity() requested_vector: np.ndarray if navmode == Navmode['Manual']: raise ValueError('Autopilot requested for manual navmode') elif navmode.name == 'CCW Prograde': if flight_state.reference == flight_state.craft: return craft.heading normal = craft.pos - ref.pos requested_vector = np.array([-normal[1], normal[0]]) elif navmode == Navmode['CW Retrograde']: if flight_state.reference == flight_state.craft: return craft.heading normal = craft.pos - ref.pos requested_vector = np.array([normal[1], -normal[0]]) elif navmode == Navmode['Depart Reference']: if flight_state.reference == flight_state.craft: return craft.heading requested_vector = craft.pos - ref.pos elif navmode == Navmode['Approach Target']: if flight_state.target == flight_state.craft: return craft.heading requested_vector = targ.pos - craft.pos elif navmode == Navmode['Pro Targ Velocity']: if flight_state.target == flight_state.craft: return craft.heading requested_vector = craft.v - targ.v elif navmode == Navmode['Anti Targ Velocity']: if flight_state.target == flight_state.craft: return craft.heading requested_vector = targ.v - craft.v else: raise ValueError(f'Got an unexpected NAVMODE: {flight_state.navmode}') return np.arctan2(requested_vector[1], requested_vector[0])
def get_state(self, requested_t=None) -> PhysicsState: """Return the latest physical state of the simulation.""" requested_t = self._simtime(requested_t) # Wait until there is a solution for our requested_t. The .wait_for() # call will block until a new ODE solution is created. with self._solutions_cond: self._last_simtime = requested_t self._solutions_cond.wait_for( # Wait until we're paused, there's a solution, or an exception. lambda: self._last_physical_state.time_acc == 0 or (len(self._solutions) != 0 and self._solutions[-1].t_max >= requested_t) or self._simthread_exception is not None ) # Check if the simthread crashed if self._simthread_exception is not None: raise self._simthread_exception if self._last_physical_state.time_acc == 0: # We're paused, so there are no solutions being generated. # Exit this 'with' block and release our _solutions_cond lock. pass else: # We can't integrate backwards, so if integration has gone # beyond what we need, fail early. assert requested_t >= self._solutions[0].t_min, \ (self._solutions[0].t_min, self._solutions[-1].t_max) for soln in self._solutions: if soln.t_min <= requested_t <= soln.t_max: solution = soln if self._last_physical_state.time_acc == 0: # We're paused, so return the only state we have. return PhysicsState(None, self._last_physical_state) else: # We have a solution, return it. newest_state = PhysicsState( solution(requested_t), self._last_physical_state ) newest_state.timestamp = requested_t return newest_state
def engine_acceleration(state: PhysicsState) -> float: """Acceleration due to engine thrust.""" craft = state.craft_entity() if craft.name == common.HABITAT and state.srb_time > 0: srb_thrust = common.SRB_THRUST else: srb_thrust = 0 return (common.craft_capabilities[craft.name].thrust * craft.throttle + srb_thrust) / (craft.mass + craft.fuel)
def test_three_body(self): """Test gravitational acceleration between three bodies is expected.""" with PhysicsEngine('tests/three-body.json') as physics_engine: # In this case, three entities form a 90-45-45 triangle, with the # entity at the right angle being about as massive as the sun. # The first entity is the massive entity, the second is far to the # left, and the third is far to the top. physics_state = physics_engine.get_state(0) # Test that every single entity has the correct accelerations. y0 = physics_state dy = PhysicsState( ode_solver.simulation_differential_function( 0, y0.y0(), y0._proto_state, physics_engine.M, physics_engine._artificials), physics_state._proto_state) self.assertEqual(len(dy.X), 3) self.assertAlmostEqual(dy.X[0], y0.VX[0]) self.assertAlmostEqual(dy.Y[0], y0.VY[0]) self.assertEqual( round(abs(dy.VX[0])), round(common.G * physics_state[1].mass / (y0.X[0] - y0.X[1])**2)) self.assertEqual( round(abs(dy.VY[0])), round(common.G * physics_state[2].mass / (y0.Y[0] - y0.Y[2])**2)) self.assertAlmostEqual(dy.X[1], y0.VX[1]) self.assertAlmostEqual(dy.Y[1], y0.VY[1]) self.assertEqual( round(abs(dy.VX[1])), round(common.G * physics_state[0].mass / (y0.X[1] - y0.X[0])**2 + np.sqrt(2) * common.G * physics_state[2].mass / (y0.X[1] - y0.X[2])**2)) self.assertEqual( round(abs(dy.VY[1])), round( np.sqrt(2) * common.G * physics_state[2].mass / (y0.X[1] - y0.X[2])**2)) self.assertAlmostEqual(dy.X[2], y0.VX[2]) self.assertAlmostEqual(dy.Y[2], y0.VY[2]) self.assertEqual( round(abs(dy.VX[2])), round( np.sqrt(2) * common.G * physics_state[2].mass / (y0.X[1] - y0.X[2])**2)) self.assertEqual( round(abs(dy.VY[2])), round(common.G * physics_state[0].mass / (y0.Y[2] - y0.Y[0])**2 + np.sqrt(2) * common.G * physics_state[1].mass / (y0.Y[2] - y0.Y[1])**2))
def get_state(self, commands: List[Request] = None) \ -> PhysicsState: if commands is None or len(commands) == 0: commands_iter = iter( [Request(ident=Request.NOOP, client=self.client_type)]) else: for command in commands: command.client = self.client_type commands_iter = iter(commands) return PhysicsState(None, self.stub.get_physical_state(commands_iter))
def __init__(self, client: protos.Command.ClientType, hostname: str): self.channel = grpc.insecure_channel(f'{hostname}:{DEFAULT_PORT}') self.stub = grpc_stubs.StateServerStub(self.channel) self.client_type = client initial_state = PhysicsState( None, self.stub.get_physical_state( iter([Request(ident=Request.NOOP, client=self.client_type)]))) self._caching_physics_engine = physics.PhysicsEngine(initial_state) self._time_of_next_network_update = 0.0
def test_y_vector_init(self): """Test that initializing with a y-vector uses y-vector values.""" y0 = np.concatenate(( np.array([ 10, 20, # x 30, 40, # y 50, 60, # vx 0, 0, # vy 0, 0, # heading 70, 80, # spin 90, 100, # fuel 0, 0, # throttle 1, -1, # only First is landed on Second 0, 1, # Second is broken common.SRB_EMPTY, 1 # time_acc ]), np.zeros(EngineeringState.N_ENGINEERING_FIELDS))) ps = PhysicsState(y0, self.proto_state) self.assertTrue(np.array_equal(ps.y0(), y0.astype(ps.y0().dtype))) self.assertEqual(ps['First'].landed_on, 'Second') proto_state = ps.as_proto() proto_state.timestamp = 50 self.assertEqual(proto_state.timestamp, 50) self.assertEqual(proto_state.entities[0].fuel, 90) self.assertTrue(proto_state.entities[1].broken)
def set_state(self, physical_state: PhysicsState): self._stop_simthread() physical_state = _reconcile_entity_dynamics(physical_state) self._artificials = np.where( np.array([entity.artificial for entity in physical_state]) >= 1)[0] # We keep track of the PhysicalState because our simulation # only simulates things that change like position and velocity, # not things that stay constant like names and mass. # self._last_physical_state contains these constants. self._last_physical_state = physical_state.as_proto() self.R = np.array([entity.r for entity in physical_state]) self.M = np.array([entity.mass for entity in physical_state]) self._start_simthread(physical_state.timestamp, physical_state)
def navmode_spin(flight_state: PhysicsState) -> float: """Returns a spin that will orient the craft according to the navmode.""" craft = flight_state.craft_entity() requested_heading = navmode_heading(flight_state) ccw_distance = (requested_heading - craft.heading) % np.radians(360) cw_distance = (craft.heading - requested_heading) % np.radians(360) if ccw_distance < cw_distance: heading_difference = ccw_distance else: heading_difference = -cw_distance if abs(heading_difference) < common.AUTOPILOT_FINE_CONTROL_RADIUS: # The unit analysis doesn't work out here I'm sorry. Basically, # the closer we are to the target heading, the slower we adjust. return heading_difference else: # We can spin at most common.AUTOPILOT_SPEED return np.sign(heading_difference) * common.AUTOPILOT_SPEED
def get_state(self, commands: List[Request] = None) \ -> PhysicsState: current_time = time.monotonic() commands_to_send = False if commands is None or len(commands) == 0: commands_iter = iter( [Request(ident=Request.NOOP, client=self.client_type)]) else: for command in commands: command.client = self.client_type commands_iter = iter(commands) commands_to_send = True returned_state: PhysicsState if commands_to_send or current_time > self._time_of_next_network_update: # We need to make a network request. # TODO: what if this fails? Do anything smarter than an exception? returned_state = PhysicsState( None, self.stub.get_physical_state(commands_iter)) self._caching_physics_engine.set_state(returned_state) if commands_to_send: # If we sent a user command, still ask for an update soon so # the user input can be reflected in hab flight's GUI as soon # as possible. # Magic number alert! The constant we add should be enough that # the physics server has had enough time to simulate the effect # of our input, but we should minimize the constant to minimize # input lag. self._time_of_next_network_update = current_time + 0.15 else: self._time_of_next_network_update = ( current_time + common.TIME_BETWEEN_NETWORK_UPDATES) else: returned_state = self._caching_physics_engine.get_state() return returned_state
def drag(flight_state: PhysicsState) -> np.ndarray: """Calculates the directional drag exerted on the craft by an atmosphere. """ atmosphere = relevant_atmosphere(flight_state) if atmosphere is None: return np.array([0, 0]) craft = flight_state.craft_entity() air_v = rotational_speed(craft, atmosphere) wind = craft.v - air_v if np.inner(wind, wind) < 0.01: # The craft is stationary return np.array([0, 0]) wind_angle = np.arctan2(wind[1], wind[0]) # I know I've said this about other pieces of code, but I really have no # idea how this code works. I just lifted it from OrbitV because I couldn't # find simple atmospheric drag models. # Also sorry, unit analysis doesn't work inside this function. # TODO: how does this work outside of the atmosphere? # TODO: disabled while I figure out if this only works during telemetry i_know_what_this_aoa_code_does = False if i_know_what_this_aoa_code_does: aoa = wind_angle - pitch(craft, atmosphere) aoa = np.sign(np.sign(np.cos(aoa)) - 1) aoa = aoa**3 if aoa > 0.5: aoa = 1 - aoa aoa_vector = -abs(aoa) * np.array([ np.sin(wind_angle + (np.pi / 2) * np.sign(aoa)), np.cos(wind_angle + (np.pi / 2) * np.sign(aoa)) ]) return aoa_vector drag_profile = common.HAB_DRAG_PROFILE if flight_state.parachute_deployed: drag_profile += common.PARACHUTE_DRAG_PROFILE drag_acc = \ pressure(craft, atmosphere) * fastnorm(wind) ** 2 * drag_profile return drag_acc * (wind / fastnorm(wind))
def clone_orbitv_state(rnd_path: Path) -> PhysicsState: """Gathers information from an OrbitV savefile, in the *.RND format, as well as a STARSr file. Returns a PhysicsState representing everything we can read.""" proto_state = protos.PhysicalState() starsr_path = rnd_path.parent / 'STARSr' log.info(f"Reading OrbitV file {rnd_path}, " f"using info from adjacent STARSr file {starsr_path}") hab_index: Optional[int] = None ayse_index: Optional[int] = None with open(starsr_path, 'r') as starsr_file: starsr = csv.reader(starsr_file, delimiter=',') background_star_line = next(starsr) while len(background_star_line) == 3: background_star_line = next(starsr) # After the last while loop, the value of background_star_line is # actually a gravity_pair line (a pair of indices, which we also don't # care about). gravity_pair_line = background_star_line while len(gravity_pair_line) == 2: gravity_pair_line = next(starsr) entity_constants_line = gravity_pair_line while len(entity_constants_line) == 6: proto_entity = proto_state.entities.add() # Cast all the elements on a line to floats. # Some strings will be the form '1.234D+04', convert these too. (colour, mass, radius, atmosphere_thickness, atmosphere_scaling, atmosphere_height) \ = map(_string_to_float, entity_constants_line) mass = max(1, mass) proto_entity.mass = mass proto_entity.r = radius if atmosphere_thickness and atmosphere_scaling: # Only set atmosphere fields if they both have a value. proto_entity.atmosphere_thickness = atmosphere_thickness proto_entity.atmosphere_scaling = atmosphere_scaling entity_constants_line = next(starsr) # We skip a line here. It's the timestamp line, but the .RND file will # have more up-to-date timestamp info. entity_positions_line = next(starsr) # We don't care about entity positions, we'll get this from the .RND # file as well. while len(entity_positions_line) == 6: entity_positions_line = next(starsr) entity_name_line = entity_positions_line entity_index = 0 while len(entity_name_line) == 1: name = entity_name_line[0].strip() proto_state.entities[entity_index].name = name if name == common.HABITAT: hab_index = entity_index elif name == common.AYSE: ayse_index = entity_index entity_index += 1 entity_name_line = next(starsr) assert proto_state.entities[0].name == common.SUN, ( 'We assume that the Sun is the first OrbitV entity, this has ' 'implications for how we populate entity positions.') assert hab_index is not None, \ "We didn't see the Habitat in the STARSr file!" assert ayse_index is not None, \ "We didn't see AYSE in the STARSr file!" hab = proto_state.entities[hab_index] ayse = proto_state.entities[ayse_index] with open(rnd_path, 'rb') as rnd: check_byte = rnd.read(1) rnd.read(len("ORBIT5S ")) craft_throttle = _read_single(rnd) / 100 # I don't know what Vflag and Aflag do. _read_int(rnd), _read_int(rnd) navmode = Navmode(SFLAG_TO_NAVMODE[_read_int(rnd)]) if navmode is not None: proto_state.navmode = navmode.value _read_double(rnd) # This is drag, we calculate this ourselves. _read_double(rnd) # This is zoom, we ignore this as well. hab.heading = _read_single(rnd) # Skip centre, ref, and targ information. _read_int(rnd), _read_int(rnd), _read_int(rnd) # We don't track if trails are set. _read_int(rnd) drag_profile = _read_single(rnd) proto_state.parachute_deployed = (drag_profile > 0.002) current_srb_output = _read_single(rnd) if current_srb_output != 0: proto_state.srb_time = common.SRB_BURNTIME # We don't care about colour trails or how times are displayed. _read_int(rnd), _read_int(rnd) _read_double(rnd), _read_double(rnd) # No time acc info either. _read_single(rnd) # Ignore some RCPS info _read_int(rnd) # Eflag? Something that gassimv.bas sets. year = _read_int(rnd) day = _read_int(rnd) hour = _read_int(rnd) minute = _read_int(rnd) second = _read_double(rnd) timestamp = datetime( year, 1, 1, hour=hour, minute=minute, second=int(second)) + timedelta(day - 1) proto_state.timestamp = timestamp.timestamp() ayse.heading = _read_single(rnd) _read_int(rnd) # AYSEscrape seems to do nothing. # TODO: Once we implement wind, fill in these fields. _read_single(rnd), _read_single(rnd) hab.spin = numpy.radians(_read_single(rnd)) docked_constant = _read_int(rnd) if docked_constant != 0: hab.landed_on = common.AYSE _read_single(rnd) # Rad shields, overwritten by enghabv.bas. # TODO: once module is implemented, have it be launched here. _read_int(rnd) # module_state = MODFLAG_TO_MODSTATE[modflag] _read_single(rnd), _read_single(rnd) # AYSEdist and OCESSdist. hab.broken = bool(_read_int(rnd)) ayse.broken = bool(_read_int(rnd)) # TODO: Once we implement nav malfunctions, set this field. _read_single(rnd) # Max thrust, ignored because we'll read it from ORBITSSE.rnd _read_single(rnd) # TODO: Once we implement nav malfunctions, set this field. # Also, apparently this is the same field as two fields ago? _read_single(rnd) # TODO: Once we implement wind, fill in these fields. _read_long(rnd) # TODO: There is some information required from MST.rnd to implement # this field (LONGtarg). _read_single(rnd) # We calculate pressure and agrav. _read_single(rnd), _read_single(rnd) # Note, we skip the first entity, the Sun, since OrbitV does. for i in range(1, min(39, len(proto_state.entities))): entity = proto_state.entities[i] entity.x, entity.y, entity.vx, entity.vy = (_read_double(rnd), _read_double(rnd), _read_double(rnd), _read_double(rnd)) hab.fuel = _read_single(rnd) ayse.fuel = _read_single(rnd) check_byte_2 = rnd.read(1) if check_byte != check_byte_2: log.warning('RND file had inconsistent check bytes: ' f'{check_byte} != {check_byte_2}') # Delete any entity with a (0, 0) position that isn't the Sun. # TODO: We also delete the OCESS launch tower, but once we implement # OCESS we should no longer do this. entity_index = 0 while entity_index < len(proto_state.entities): proto_entity = proto_state.entities[entity_index] if round(proto_entity.x) == 0 and round(proto_entity.y) == 0 and \ proto_entity.name != common.SUN or \ proto_entity.name == common.OCESS: del proto_state.entities[entity_index] else: entity_index += 1 hab.artificial = True ayse.artificial = True # Habitat and AYSE masses are hardcoded in OrbitV. hab.mass = 275000 ayse.mass = 20000000 orbitx_state = PhysicsState(None, proto_state) orbitx_state[common.HABITAT] = Entity(hab) orbitx_state[common.AYSE] = Entity(ayse) craft = orbitx_state.craft_entity() craft.throttle = craft_throttle log.debug(f'Interpreted {rnd_path} and {starsr_path} into this state:') log.debug(repr(orbitx_state)) orbitx_state = _separate_landed_entities(orbitx_state) return orbitx_state
def test_landed_on(self): """Test that the special .landed_on field is properly set.""" ps = PhysicsState(None, self.proto_state) self.assertEqual(ps['First'].landed_on, '') self.assertEqual(ps['Second'].landed_on, 'First')
def _run_simulation(self, t: float, y: PhysicsState) -> None: # An overview of how time is managed: # # self._last_simtime is the main thread's latest idea of # what the current time is in the simulation. Every call to # get_state(), self._timetime_of_last_request is incremented by the # amount of time that passed since the last call to get_state(), # factoring in time_acc # # self._solutions is a fixed-size queue of ODE solutions. # Each element has an attribute, t_max, which describes the largest # time that the solution can be evaluated at and still be accurate. # The highest such t_max should always be larger than the current # simulation time, i.e. self._last_simtime proto_state = y._proto_state while not self._stopping_simthread: derive_func = functools.partial( self._derive, pass_through_state=proto_state) events: List[Event] = [ CollisionEvent(y, self.R), HabFuelEvent(y), LiftoffEvent(y), SrbFuelEvent(), HabReactorTempEvent(), AyseReactorTempEvent() ] if y.craft is not None: events.append(HighAccEvent( derive_func, self._artificials, TIME_ACC_TO_BOUND[round(y.time_acc)], y.time_acc, len(y))) ivp_out = scipy.integrate.solve_ivp( fun=derive_func, t_span=[t, t + min(y.time_acc, 10 * self.MAX_STEP_SIZE)], # solve_ivp requires a 1D y0 array y0=y.y0(), events=events, dense_output=True, max_step=self.MAX_STEP_SIZE ) if not ivp_out.success: # Integration error raise Exception(ivp_out.message) # When we create a new solution, let other people know. with self._solutions_cond: # If adding another solution to our max-sized deque would drop # our oldest solution, and the main thread is still asking for # state in the t interval of our oldest solution, take a break # until the main thread has caught up. self._solutions_cond.wait_for( lambda: len(self._solutions) < SOLUTION_CACHE_SIZE or self._last_simtime > self._solutions[0].t_max or self._stopping_simthread ) if self._stopping_simthread: break # self._solutions contains ODE solutions for the interval # [self._solutions[0].t_min, self._solutions[-1].t_max]. self._solutions.append(ivp_out.sol) self._solutions_cond.notify_all() y = PhysicsState(ivp_out.y[:, -1], proto_state) t = ivp_out.t[-1] if ivp_out.status > 0: log.info(f'Got event: {ivp_out.t_events} at t={t}.') for index, event_t in enumerate(ivp_out.t_events): if len(event_t) == 0: # If this event didn't occur, then event_t == [] continue event = events[index] if isinstance(event, CollisionEvent): # Collision, simulation ended. Handled it and continue. assert len(ivp_out.t_events[0]) == 1 assert len(ivp_out.t) >= 2 y = _collision_decision(t, y, events[0]) y = _reconcile_entity_dynamics(y) if isinstance(event, HabFuelEvent): # Something ran out of fuel. for artificial_index in self._artificials: artificial = y[artificial_index] if round(artificial.fuel) != 0: continue log.info(f'{artificial.name} ran out of fuel.') # This craft is out of fuel, the next iteration # won't consume any fuel. Set throttle to zero. artificial.throttle = 0 # Set fuel to a negative value, so it doesn't # trigger the event function. artificial.fuel = 0 if isinstance(event, LiftoffEvent): # A craft has a TWR > 1 craft = y.craft_entity() log.info( 'We have liftoff of the ' f'{craft.name} from {craft.landed_on} at {t}.') craft.landed_on = '' if isinstance(event, SrbFuelEvent): # SRB fuel exhaustion. log.info('SRB exhausted.') y.srb_time = common.SRB_EMPTY if isinstance(event, HighAccEvent): # The acceleration acting on the craft is high, might # result in inaccurate results. SLOOWWWW DOWWWWNNNN. slower_time_acc_index = list( TIME_ACC_TO_BOUND.keys() ).index(round(y.time_acc)) - 1 assert slower_time_acc_index >= 0 slower_time_acc = \ common.TIME_ACCS[slower_time_acc_index] assert slower_time_acc.value > 0 log.info( f'{y.time_acc} is too fast, ' f'slowing down to {slower_time_acc.value}') # We should lower the time acc. y.time_acc = slower_time_acc.value raise PhysicsEngine.RestartSimulationException(t, y) if isinstance(event, HabReactorTempEvent): y.engineering.hab_reactor_alarm = not y.engineering.hab_reactor_alarm if isinstance(event, AyseReactorTempEvent): y.engineering.ayse_reactor_alarm = not y.engineering.ayse_reactor_alarm
def _derive(self, t: float, y_1d: np.ndarray, pass_through_state: PhysicalState) -> np.ndarray: """ y_1d = [X, Y, VX, VY, Heading, Spin, Fuel, Throttle, LandedOn, Broken] + SRB_time_left + time_acc (these are both single values) + [ComponentData, CoolantLoopData, RadiatorData] returns the derivative of y_1d, i.e. [VX, VY, AX, AY, Spin, 0, Fuel consumption, 0, 0, 0] + -constant + 0 (zeroed-out fields are changed elsewhere) !!!!!!!!!!! IMPORTANT !!!!!!!!!!! This function should return a DERIVATIVE. The numpy.solve_ivp function will do the rest of the work of the simulation, this function just describes how things _move_. At its most basic level, this function takes in the _position_ of everything (plus some random stuff), and returns the _velocity_ of everything (plus some random stuff). Essentially, numpy.solve_ivp does this calculation: new_positions_of_system = t_delta * _derive( current_t_of_system, current_y_of_system) """ # Note: we create this y as a PhysicsState for convenience, but if you # set any values of y, the changes will be discarded! The only way they # will be propagated out of this function is by numpy using the return # value of this function as a derivative, as explained above. # If you want to set values in y, look at _reconcile_entity_dynamics. y = PhysicsState(y_1d, pass_through_state) acc_matrix = calc.grav_acc(y.X, y.Y, self.M, y.Fuel) zeros = np.zeros(y._n) fuel_cons = np.zeros(y._n) # Engine thrust and fuel consumption for artif_index in self._artificials: if y[artif_index].fuel > 0 and y[artif_index].throttle > 0: # We have fuel remaining, calculate thrust entity = y[artif_index] capability = common.craft_capabilities[entity.name] fuel_cons[artif_index] = \ -abs(capability.fuel_cons * entity.throttle) eng_thrust = (capability.thrust * entity.throttle * calc.heading_vector(entity.heading)) mass = entity.mass + entity.fuel if entity.name == AYSE and \ y[HABITAT].landed_on == AYSE: # It's bad that this is hardcoded, but it's also the only # place that this comes up so IMO it's not too bad. hab = y[HABITAT] mass += hab.mass + hab.fuel eng_acc = eng_thrust / mass acc_matrix[artif_index] += eng_acc # And SRB thrust srb_usage = 0 try: if y.srb_time >= 0: hab_index = y._name_to_index(HABITAT) hab = y[hab_index] srb_acc = common.SRB_THRUST / (hab.mass + hab.fuel) srb_acc_vector = srb_acc * calc.heading_vector(hab.heading) acc_matrix[hab_index] += srb_acc_vector srb_usage = -1 except PhysicsState.NoEntityError: # The Habitat doesn't exist. pass # Engineering values R = y.engineering.components.Resistance() I = y.engineering.components.Current() # noqa: E741 # Eventually radiators will affect the temperature. T_deriv = common.eta * np.square(I) * R R_deriv = common.alpha * T_deriv # Voltage we set to be constant. Since I = V/R, I' = V'/R' = 0/R' = 0 V_deriv = I_deriv = np.zeros(data_structures._N_COMPONENTS) # Drag effects craft = y.craft if craft is not None: craft_index = y._name_to_index(y.craft) drag_acc = calc.drag(y) acc_matrix[craft_index] -= drag_acc # Centripetal acceleration to keep landed entities glued to each other. landed_on = y.LandedOn for landed_i in landed_on: lander = y[landed_i] ground = y[landed_on[landed_i]] centripetal_acc = (lander.pos - ground.pos) * ground.spin ** 2 acc_matrix[landed_i] = \ acc_matrix[landed_on[landed_i]] - centripetal_acc # Sets velocity and spin of a couple more entities. # If you want to set the acceleration of an entity, do it above and # keep that logic in _derive. If you want to set the velocity and spin # or any other fields that an Entity has, you should put that logic in # this _reconcile_entity_dynamics helper. y = _reconcile_entity_dynamics(y) return np.concatenate(( y.VX, y.VY, np.hsplit(acc_matrix, 2), y.Spin, zeros, fuel_cons, zeros, zeros, zeros, np.array([srb_usage, 0]), np.zeros(data_structures._N_COMPONENTS), # connected field doesn't change T_deriv, R_deriv, V_deriv, I_deriv, np.zeros(data_structures._N_COMPONENTS), # attached_to_coolant_loop field doesn't change np.zeros(data_structures._N_COOLANT_LOOPS * data_structures._N_COOLANT_FIELDS), np.zeros(data_structures._N_RADIATORS * data_structures._N_RADIATOR_FIELDS) ), axis=None)
def _one_request(request: Request, y0: PhysicsState) \ -> PhysicsState: """Interface to set habitat controls. Use an argument to change habitat throttle or spinning, and simulation will restart with this new information.""" log.info(f'At simtime={y0.timestamp}, ' f'Got command {MessageToString(request, as_one_line=True)}') if request.ident != Request.TIME_ACC_SET: # Reveal the type of y0.craft as str (not None). assert y0.craft is not None if request.ident == Request.HAB_SPIN_CHANGE: if y0.navmode != Navmode['Manual']: # We're in autopilot, ignore this command return y0 craft = y0.craft_entity() if not craft.landed(): craft.spin += request.spin_change elif request.ident == Request.HAB_THROTTLE_CHANGE: y0.craft_entity().throttle += request.throttle_change elif request.ident == Request.HAB_THROTTLE_SET: y0.craft_entity().throttle = request.throttle_set elif request.ident == Request.TIME_ACC_SET: assert request.time_acc_set >= 0 y0.time_acc = request.time_acc_set elif request.ident == Request.ENGINEERING_UPDATE: # Multiply this value by 100, because OrbitV considers engines at # 100% to be 100x the maximum thrust. common.craft_capabilities[HABITAT] = \ common.craft_capabilities[HABITAT]._replace( thrust=100 * request.engineering_update.max_thrust) hab = y0[HABITAT] ayse = y0[AYSE] hab.fuel = request.engineering_update.hab_fuel ayse.fuel = request.engineering_update.ayse_fuel y0[HABITAT] = hab y0[AYSE] = ayse if request.engineering_update.module_state == \ Request.DETACHED_MODULE and \ MODULE not in y0._entity_names and \ not hab.landed(): # If the Habitat is freely floating and engineering asks us to # detach the Module, spawn in the Module. module = Entity(protos.Entity( name=MODULE, mass=100, r=10, artificial=True)) module.pos = (hab.pos - (module.r + hab.r) * calc.heading_vector(hab.heading)) module.v = calc.rotational_speed(module, hab) y0_proto = y0.as_proto() y0_proto.entities.extend([module.proto]) y0 = PhysicsState(None, y0_proto) elif request.ident == Request.UNDOCK: habitat = y0[HABITAT] if habitat.landed_on == AYSE: ayse = y0[AYSE] habitat.landed_on = '' norm = habitat.pos - ayse.pos unit_norm = norm / calc.fastnorm(norm) habitat.v += unit_norm * common.UNDOCK_PUSH habitat.spin = ayse.spin y0[HABITAT] = habitat elif request.ident == Request.REFERENCE_UPDATE: y0.reference = request.reference elif request.ident == Request.TARGET_UPDATE: y0.target = request.target elif request.ident == Request.LOAD_SAVEFILE: y0 = common.load_savefile(common.savefile(request.loadfile)) elif request.ident == Request.NAVMODE_SET: y0.navmode = Navmode(request.navmode) if y0.navmode == Navmode['Manual']: y0.craft_entity().spin = 0 elif request.ident == Request.PARACHUTE: y0.parachute_deployed = request.deploy_parachute elif request.ident == Request.IGNITE_SRBS: if round(y0.srb_time) == common.SRB_FULL: y0.srb_time = common.SRB_BURNTIME elif request.ident == Request.TOGGLE_COMPONENT: component = y0.engineering.components[request.component_to_toggle] component.connected = not component.connected elif request.ident == Request.TOGGLE_RADIATOR: radiator = y0.engineering.radiators[request.radiator_to_toggle] radiator.functioning = not radiator.functioning elif request.ident == Request.CONNECT_RADIATOR_TO_LOOP: radiator = y0.engineering.radiators[request.radiator_to_loop.rad] radiator.attached_to_coolant_loop = request.radiator_to_loop.loop elif request.ident == Request.TOGGLE_COMPONENT_COOLANT: component = y0.engineering.components[request.component_to_loop.component] # See comments on _component_coolant_cnxn_transitions for more info. component.coolant_connection = ( _component_coolant_cnxn_transitions [request.component_to_loop.loop] [component.coolant_connection] ) return y0
def _update_obj(self, entity: Entity, state: PhysicsState, origin: Entity): super()._update_obj(entity, state, origin) self._obj.boosters.pos = self._obj.pos self._obj.boosters.axis = self._obj.axis # Attach the parachute to the forward cone of the habitat. self._obj.parachute.pos = ( self._obj.pos + calc.angle_to_vpy(entity.heading) * entity.r * 0.8) parachute_is_visible = ((state.craft == entity.name) and state.parachute_deployed) if parachute_is_visible: drag = calc.drag(state) drag_mag = np.inner(drag, drag) for parachute in [self._obj.parachute, self._small_habitat.parachute]: if not parachute_is_visible or drag_mag < 0.00001: # If parachute_is_visible == False, don't show the parachute. # If the drag is basically zero, don't show the parachute. parachute.visible = False continue if drag_mag > 0.1: parachute.width = self.PARACHUTE_RADIUS * 2 parachute.height = self.PARACHUTE_RADIUS * 2 else: # Below a certain threshold the parachute deflates. parachute.width = self.PARACHUTE_RADIUS parachute.height = self.PARACHUTE_RADIUS parachute.axis = -vpython.vec(*drag, 0) parachute.visible = True if not self._broken and entity.broken: # We weren't broken before, but looking at new data we realize # we're now broken. Change the habitat texture. new_texture = self._obj.texture.replace('Habitat.jpg', 'Habitat-broken.jpg') assert new_texture != self._obj.texture, \ f'{new_texture!r} == {self._obj.texture!r}' self._obj.texture = new_texture self._small_habitat.texture = new_texture self._broken = entity.broken elif self._broken and not entity.broken: # We were broken before, but we've repaired ourselves somehow. new_texture = self._obj.texture.replace('Habitat-broken.jpg', 'Habitat.jpg') assert new_texture != self._obj.texture, \ f'{new_texture!r} == {self._obj.texture!r}' self._obj.texture = new_texture self._small_habitat.texture = new_texture self._broken = entity.broken # Set reference and target arrows of the minimap habitat. same = state.reference == entity.name default = vpython.vector(0, 0, -1) ref_arrow_axis = (entity.screen_pos(state.reference_entity()).norm() * entity.r * -1.2) v = entity.v - state.reference_entity().v velocity_arrow_axis = \ vpython.vector(*v, 0).norm() * entity.r self._ref_arrow.axis = default if same else ref_arrow_axis self._velocity_arrow.axis = default if same else velocity_arrow_axis self._small_habitat.axis = self._obj.axis self._small_habitat.boosters.axis = self._obj.axis # Invisible-ize the SRBs if we ran out. if state.srb_time == common.SRB_EMPTY and self._obj.boosters.visible: self._obj.boosters.visible = False self._small_habitat.boosters.visible = False