class TestSimulation(unittest.TestCase): sim: Simulation = None def setUp(self): if self.sim: self.sim.close() self.sim = Simulation() def tearDown(self): self.sim = None def test_init_jsbsim(self): self.assertIsInstance(self.sim.jsbsim, jsbsim.FGFDMExec, msg=f'Expected Simulation.jsbsim to hold an ' 'instance of JSBSim.') def test_load_model(self): plane = aircraft.a320 self.sim = None self.sim = Simulation(aircraft=plane) actual_name = self.sim.get_loaded_model_name() self.assertEqual(plane.jsbsim_id, actual_name, msg=f'Unexpected aircraft model name after loading.') def test_load_bad_aircraft_id(self): bad_name = 'qwertyuiop' bad_aircraft = aircraft.Aircraft(bad_name, '', '', 100.) with self.assertRaises(RuntimeError): self.sim = None self.sim = Simulation(aircraft=bad_aircraft) def test_get_property(self): self.setUp() # we expect certain values specified in the IC config XML file expected_values = { prp.initial_u_fps: 328.0, prp.initial_v_fps: 0.0, prp.initial_w_fps: 0.0, prp.u_fps: 328.0, prp.v_fps: 0.0, prp.w_fps: 0.0, } for prop, expected in expected_values.items(): actual = self.sim[prop] self.assertAlmostEqual(expected, actual) def test_get_bad_property(self): self.setUp() bad_prop = prp.BoundedProperty("bad_prop_name", "", 0, 0) with self.assertRaises(KeyError): _ = self.sim[bad_prop] def test_set_property(self): self.setUp() set_values = { prp.altitude_sl_ft: 1000, prp.aileron_cmd: 0.2, prp.elevator_cmd: 0.2, prp.rudder_cmd: 0.2, prp.throttle_cmd: 0.2, } for prop, value in set_values.items(): self.sim[prop] = value for prop, expected in set_values.items(): actual = self.sim[prop] self.assertAlmostEqual(expected, actual) def test_initialise_conditions_basic_config(self): plane = aircraft.f15 # manually reset JSBSim instance with new initial conditions if self.sim: self.sim.close() sim_frequency = 2 self.sim = Simulation(sim_frequency_hz=sim_frequency, aircraft=plane, init_conditions=None) self.assertEqual(self.sim.get_loaded_model_name(), plane.jsbsim_id, msg='JSBSim did not load expected aircraft model: ' + self.sim.get_loaded_model_name()) # check that properties are as we expected them to be expected_values = { prp.initial_u_fps: 328.0, prp.initial_v_fps: 0.0, prp.initial_w_fps: 0.0, prp.u_fps: 328.0, prp.v_fps: 0.0, prp.w_fps: 0.0, prp.BoundedProperty('simulation/dt', '', None, None): 1 / sim_frequency } for prop, expected in expected_values.items(): actual = self.sim[prop] self.assertAlmostEqual(expected, actual) def test_initialise_conditions_custom_config(self): """ Test JSBSimInstance initialisation with custom initial conditions. """ plane = aircraft.f15 init_conditions = { prp.initial_u_fps: 1000.0, prp.initial_v_fps: 0.0, prp.initial_w_fps: 1.0, prp.initial_altitude_ft: 5000, prp.initial_heading_deg: 12, prp.initial_r_radps: -0.1, } # map JSBSim initial condition properties to sim properties init_to_sim_conditions = { prp.initial_u_fps: prp.u_fps, prp.initial_v_fps: prp.v_fps, prp.initial_w_fps: prp.w_fps, prp.initial_altitude_ft: prp.altitude_sl_ft, prp.initial_heading_deg: prp.heading_deg, prp.initial_r_radps: prp.r_radps, } sim_frequency = 10 # manually reset JSBSim instance if self.sim: self.sim.close() self.sim = Simulation(sim_frequency, plane, init_conditions) # check JSBSim initial condition and simulation properties for init_prop, expected in init_conditions.items(): sim_prop = init_to_sim_conditions[init_prop] init_actual = self.sim[init_prop] sim_actual = self.sim[sim_prop] self.assertAlmostEqual(expected, init_actual, msg=f'wrong value for property {init_prop}') self.assertAlmostEqual(expected, sim_actual, msg=f'wrong value for property {sim_prop}') self.assertAlmostEqual(1.0 / sim_frequency, self.sim[prp.sim_dt]) def test_multiprocess_simulations(self): """ JSBSim segfaults when multiple instances are run on one process. Let's confirm that we can launch multiple processes each with 1 instance. """ processes = 4 with multiprocessing.Pool(processes) as pool: # N.B. basic_task is a top level function that inits JSBSim future_results = [pool.apply_async(basic_task) for _ in range(processes)] results = [f.get() for f in future_results] good_exit_code = 0 expected = [good_exit_code] * processes self.assertListEqual(results, expected, msg="multiprocess execution of JSBSim failed")
class JSBSimEnv(gym.Env): """ A class wrapping the JSBSim flight dynamics module (FDM) for simulating aircraft as an RL environment conforming to the OpenAI Gym Env interface. An JsbSimEnv is instantiated with a Task that implements a specific aircraft control task with its own specific observation/action space and variables and agent_reward calculation. ATTRIBUTION: this class implements the OpenAI Gym Env API. Method docstrings have been adapted or copied from the OpenAI Gym source code. """ metadata = {'render.modes': ['human', 'csv']} def __init__(self, task): """ Constructor. Init some internal state, but JSBSimEnv.reset() must be called first before interacting with environment. :param task: the Task for the task agent is to perform """ self.sim = None self.task = task() self.observation_space = self.task.get_observation_space() # None self.action_space = self.task.get_action_space() # None self.state = None def step(self, action=None): """ Run one timestep of the environment's dynamics. When end of episode is reached, you are responsible for calling `reset()` to reset this environment's state. Accepts an action and returns a tuple (observation, reward, done, info). :param action: np.array, the agent's action, with same length as action variables. :return: state: agent's observation of the current environment reward: amount of reward returned after previous action done: whether the episode has ended, in which case further step() calls are undefined info: auxiliary information """ if action is not None: #print(action, self.action_space) #nb_action = 0 # for x in action: # nb_action += 1 # print(nb_action) # print(len(self.action_space.spaces)) if not len(action) == len(self.action_space.spaces): raise ValueError( 'mismatch between action and action space size') self.state = self.make_step(action) reward, done, info = self.task.get_reward( self.state, self.sim), self.is_terminal(), {} state = self.state if not done else self._get_clipped_state( ) # returned state should be in observation_space return state, reward, done, info def make_step(self, action=None): """ Calculates new state. :param action: array of floats, the agent's last action :return: observation: array, agent's observation of the environment state """ # take actions if action is not None: self.sim.set_property_values(self.task.get_action_var(), action) # run simulation self.sim.run() return self.get_observation() def reset(self): """ Resets the state of the environment and returns an initial observation. :return: array, the initial observation of the space. """ if self.sim: self.sim.close() self.sim = Simulation( aircraft_name=self.task.aircraft_name, init_conditions=self.task.init_conditions, jsbsim_freq=self.task.jsbsim_freq, agent_interaction_steps=self.task.agent_interaction_steps) self.state = self.get_observation() self.observation_space = self.task.get_observation_space() self.action_space = self.task.get_action_space() return self.state def is_terminal(self): """ Checks if the state is terminal. :return: bool """ is_not_contained = not self.observation_space.contains(self.state) return is_not_contained or self.task.is_terminal(self.state, self.sim) def render(self, mode='human', **kwargs): """Renders the environment. The set of supported modes varies per environment. (And some environments do not support rendering at all.) By convention, if mode is: - human: print on the terminal - csv: output to cvs files Note: Make sure that your class's metadata 'render.modes' key includes the list of supported modes. It's recommended to call super() in implementations to use the functionality of this method. :param mode: str, the mode to render with """ return self.task.render(self.sim, mode=mode, **kwargs) def seed(self, seed=None): """ Sets the seed for this env's random number generator(s). Note: Some environments use multiple pseudorandom number generators. We want to capture all such seeds used in order to ensure that there aren't accidental correlations between multiple generators. Returns: list<bigint>: Returns the list of seeds used in this env's random number generators. The first value in the list should be the "main" seed, or the value which a reproducer should pass to 'seed'. Often, the main seed equals the provided 'seed', but this won't be true if seed=None, for example. """ return def close(self): """ Cleans up this environment's objects Environments automatically close() when garbage collected or when the program exits. """ if self.sim: self.sim.close() def get_observation(self): """ get state observation from sim. :return: NamedTuple, the first state observation of the episode """ obs_list = self.sim.get_property_values( self.task.get_observation_var()) return tuple([np.array([obs]) for obs in obs_list]) def get_sim_time(self): """ Gets the simulation time from sim, a float. """ return self.sim.get_sim_time() def get_state(self): return self.sim.get_sim_state() def _get_clipped_state(self): clipped = [ np.clip(self.state[i], o.low, o.high) if self.task.state_var[i].clipped else self.state[i] for i, o in enumerate(self.observation_space) ] return tuple(clipped) def set_state(self, state): self.sim.set_sim_state(state) self.state = self.get_observation()