Exemple #1
0
    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())
Exemple #2
0
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
Exemple #3
0
    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)
Exemple #4
0
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
Exemple #5
0
 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"
Exemple #6
0
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)
Exemple #7
0
    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
Exemple #8
0
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])
Exemple #9
0
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
Exemple #10
0
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))
Exemple #11
0
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
Exemple #12
0
    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 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 _write_state_to_osbackup(orbitx_state: PhysicsState, osbackup_path: Path,
                             orbitv_names: List[str]):
    """Writes information to OSbackup.RND. orbitv_names should be the result
    of entity_list_from_starsr.

    Currently only writes information relevant to engineering, but if we
    want to communicate piloting state to other legacy orbit programs we
    can write other kinds of information. I found out what information I
    should be writing here by reading the source for engineering
    (enghabv.bas) and seeing what information it reads from OSbackup.RND"""
    assert orbitx_state.craft

    hab = orbitx_state[common.HABITAT]
    ayse = orbitx_state[common.AYSE]
    craft = orbitx_state.craft_entity()
    max_thrust = common.craft_capabilities[craft.name].thrust
    timestamp = datetime.fromtimestamp(orbitx_state.timestamp)
    check_byte = random.choice(string.ascii_letters).encode('ascii')
    target = (orbitx_state.target
              if orbitx_state.target in orbitv_names else "AYSE")
    reference = (orbitx_state.reference
                 if orbitx_state.reference in orbitv_names else "Earth")
    atmosphere = calc.relevant_atmosphere(orbitx_state)
    if atmosphere is None:
        pressure = 0.0
    else:
        pressure = calc.pressure(craft, atmosphere)
    craft_thrust = (max_thrust * craft.throttle *
                    calc.heading_vector(craft.heading))
    craft_drag = calc.drag(orbitx_state)
    artificial_gravity = numpy.linalg.norm(craft_thrust - craft_drag)

    with open(osbackup_path, 'r+b') as osbackup:
        # We replicate the logic of orbit5v.bas:1182 (look for label 800)
        # in the below code. OSBACKUP.RND is a binary, fixed-width field
        # file. orbit5v.bas writes to it every second, and it's created in
        # the same way as any OrbitV save file.
        # If you're reading the orbit5v.bas source code, note that arrays
        # and strings in QB are 1-indexed not 0-indexed.

        osbackup.write(check_byte)
        osbackup.write("ORBIT5S        ".encode('ascii'))
        # OrbitX keeps throttle as a float, where 100% = 1.0
        # OrbitV expects 100% = 100.0
        _write_single(100 * max(hab.throttle, ayse.throttle), osbackup)

        # This is Vflag and Aflag, dunno what these are.
        _write_int(0, osbackup)
        _write_int(0, osbackup)

        _write_int(NAVMODE_TO_SFLAG[orbitx_state.navmode], osbackup)
        _write_double(numpy.linalg.norm(calc.drag(orbitx_state)), osbackup)
        _write_double(25, osbackup)  # A sane value for OrbitV zoom.
        # TODO: check if this is off by 90 degrees or something.
        _write_single(hab.heading, osbackup)
        _write_int(orbitv_names.index("Habitat"), osbackup)  # Centre
        _write_int(orbitv_names.index(target), osbackup)
        _write_int(orbitv_names.index(reference), osbackup)

        _write_int(0, osbackup)  # Set trails to off.
        _write_single(0.006 if orbitx_state.parachute_deployed else 0.002,
                      osbackup)
        _write_single(
            (common.SRB_THRUST / 100) if orbitx_state.srb_time > 0 else 0,
            osbackup)
        _write_int(0, osbackup)  # No colour-coded trails.

        # Valid values of this are 0, 1, 2.
        # A value of 1 gets OrbitV to display EST timestamps.
        _write_int(1, osbackup)
        _write_double(0.125, osbackup)  # Set timestep to the slowest.
        _write_double(0.125, osbackup)  # This is OLDts, also slowest value
        _write_single(0, osbackup)  # Something to do with RSCP, zero it.
        _write_int(0, osbackup)  # Eflag? Something that gassimv.bas sets.

        _write_int(timestamp.year, osbackup)
        # QB uses day-of-the-year instead of day-of-the-month.
        _write_int(int(timestamp.strftime('%j')), osbackup)
        _write_int(timestamp.hour, osbackup)
        _write_int(timestamp.minute, osbackup)
        _write_double(timestamp.second, osbackup)

        _write_single(ayse.heading, osbackup)
        _write_int(0, osbackup)  # AYSEscrape seemingly does nothing.

        # TODO: Once we implement wind, fill in these fields.
        _write_single(0, osbackup)
        _write_single(0, osbackup)

        _write_single(numpy.degrees(hab.spin), osbackup)
        _write_int(150 if hab.landed_on == common.AYSE else 0, osbackup)
        _write_single(0, osbackup)  # Rad shields, written by enghabv.bas.
        _write_int(
            MODSTATE_TO_MODFLAG[network.Request.DETACHED_MODULE]
            if common.MODULE in orbitx_state._entity_names else
            MODSTATE_TO_MODFLAG[network.Request.NO_MODULE], osbackup)

        # These next two variables are technically AYSEdist and OCESSdist
        # but they're used for engineering to determine fueld loading from
        # OCESS or AYSE. Also, if you can dock with AYSE. So we just tell
        # engineering if we're landed, but don't tell it any more deets.
        _write_single(150 if hab.landed_on == common.AYSE else 1000, osbackup)
        _write_single(150 if hab.landed_on == common.EARTH else 1000, osbackup)

        _write_int(hab.broken, osbackup)  # I think an explosion flag.
        _write_int(ayse.broken, osbackup)  # Same as above.

        # TODO: Once we implement nav malfunctions, set this field.
        _write_single(0, osbackup)

        _write_single(max_thrust, osbackup)

        # TODO: Once we implement nav malfunctions, set this field.
        # Also, apparently this is the same field as two fields ago?
        _write_single(0, osbackup)

        # TODO: Once we implement wind, fill in these fields.
        _write_long(0, osbackup)

        # TODO: Some information required from MST.rnd needed to implement.
        # this field (LONGtarg).
        _write_single(0, osbackup)

        _write_single(pressure, osbackup)
        _write_single(artificial_gravity, osbackup)

        for i in range(0, 39):
            try:
                entity = orbitx_state[orbitv_names[i]]
                x, y = entity.pos
                vx, vy = entity.v
            except ValueError:
                x = y = vx = vy = 0
            _write_double(x, osbackup)
            _write_double(y, osbackup)
            _write_double(vx, osbackup)
            _write_double(vy, osbackup)

        _write_single(hab.fuel, osbackup)
        _write_single(ayse.fuel, osbackup)

        osbackup.write(check_byte)