diff --git a/Dockerfile b/Dockerfile index 5391343..2feb5a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ FROM python:3.4-onbuild +RUN pip install '.[web]' + ENTRYPOINT ["python", "-m", "soil"] diff --git a/docker-compose.yml b/docker-compose.yml index eccac5a..dfb338e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,3 +6,5 @@ services: - .:/usr/src/app tty: true entrypoint: /bin/bash + ports: + - '8001:8001' diff --git a/setup.py b/setup.py index af30134..4896213 100644 --- a/setup.py +++ b/setup.py @@ -40,10 +40,15 @@ setup( 'Operating System :: POSIX', 'Programming Language :: Python :: 3'], install_requires=install_reqs, + extras_require={ + 'web': ['tornado'] + + }, tests_require=test_reqs, setup_requires=['pytest-runner', ], include_package_data=True, entry_points={ 'console_scripts': - ['soil = soil.__init__:main'] + ['soil = soil.__init__:main', + 'soil-web = soil.web.__init__:main'] }) diff --git a/soil/VERSION b/soil/VERSION index 2bb6a82..d33c3a2 100644 --- a/soil/VERSION +++ b/soil/VERSION @@ -1 +1 @@ -0.11.3 \ No newline at end of file +0.12.0 \ No newline at end of file diff --git a/soil/history.py b/soil/history.py index 72ced57..37720a5 100644 --- a/soil/history.py +++ b/soil/history.py @@ -23,16 +23,14 @@ class History: if backup and os.path.exists(db_path): newname = db_path + '.backup{}.sqlite'.format(time.time()) os.rename(db_path, newname) - self._db_path = db_path - if isinstance(db_path, str): - self._db = sqlite3.connect(db_path) - else: - self._db = db_path + self.db_path = db_path + + self.db = db_path - with self._db: - self._db.execute('''CREATE TABLE IF NOT EXISTS history (agent_id text, t_step int, key text, value text text)''') - self._db.execute('''CREATE TABLE IF NOT EXISTS value_types (key text, value_type text)''') - self._db.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_history ON history (agent_id, t_step, key);''') + with self.db: + self.db.execute('''CREATE TABLE IF NOT EXISTS history (agent_id text, t_step int, key text, value text text)''') + self.db.execute('''CREATE TABLE IF NOT EXISTS value_types (key text, value_type text)''') + self.db.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_history ON history (agent_id, t_step, key);''') self._dtypes = {} self._tups = [] @@ -41,6 +39,22 @@ class History: if key not in self._dtypes: self.read_types() return self._dtypes[key] + + @property + def db(self): + try: + self._db.cursor() + except sqlite3.ProgrammingError: + self.db = None # Reset the database + return self._db + + @db.setter + def db(self, db_path=None): + db_path = db_path or self.db_path + if isinstance(db_path, str): + self._db = sqlite3.connect(db_path) + else: + self._db = db_path @property def dtypes(self): @@ -50,7 +64,7 @@ class History: self.save_records(Record(*tup) for tup in tuples) def save_records(self, records): - with self._db: + with self.db: for rec in records: if not isinstance(rec, Record): rec = Record(*rec) @@ -59,8 +73,8 @@ class History: serializer = utils.serializer(name) deserializer = utils.deserializer(name) self._dtypes[rec.key] = (name, serializer, deserializer) - self._db.execute("replace into value_types (key, value_type) values (?, ?)", (rec.key, name)) - self._db.execute("replace into history(agent_id, t_step, key, value) values (?, ?, ?, ?)", (rec.agent_id, rec.t_step, rec.key, rec.value)) + self.db.execute("replace into value_types (key, value_type) values (?, ?)", (rec.key, name)) + self.db.execute("replace into history(agent_id, t_step, key, value) values (?, ?, ?, ?)", (rec.agent_id, rec.t_step, rec.key, rec.value)) def save_record(self, *args, **kwargs): self._tups.append(Record(*args, **kwargs)) @@ -77,16 +91,16 @@ class History: def to_tuples(self): self.flush_cache() - with self._db: - res = self._db.execute("select agent_id, t_step, key, value from history ").fetchall() + with self.db: + res = self.db.execute("select agent_id, t_step, key, value from history ").fetchall() for r in res: agent_id, t_step, key, value = r _, _ , des = self.conversors(key) yield agent_id, t_step, key, des(value) def read_types(self): - with self._db: - res = self._db.execute("select key, value_type from value_types ").fetchall() + with self.db: + res = self.db.execute("select key, value_type from value_types ").fetchall() for k, v in res: serializer = utils.serializer(v) deserializer = utils.deserializer(v) @@ -143,7 +157,7 @@ class History: h1.key = h2.key and h1.t_step = h2.t_step '''.format(condition=condition) - last_df = pd.read_sql_query(last_query, self._db) + last_df = pd.read_sql_query(last_query, self.db) filters.append("t_step >= '{}' and t_step <= '{}'".format(min_step, max(t_steps))) @@ -151,7 +165,7 @@ class History: if filters: condition = 'where {} '.format(' and '.join(filters)) query = 'select * from history {} limit {}'.format(condition, limit) - df = pd.read_sql_query(query, self._db) + df = pd.read_sql_query(query, self.db) if last_df is not None: df = pd.concat([df, last_df]) diff --git a/soil/simulation.py b/soil/simulation.py index da5dbdf..f9aad8e 100644 --- a/soil/simulation.py +++ b/soil/simulation.py @@ -49,7 +49,7 @@ class SoilSimulation(NetworkSimulation): default_state=None, interval=1, dump=None, dry_run=False, dir_path=None, num_trials=1, max_time=100, agent_module=None, load_module=None, seed=None, - environment_agents=None, environment_params=None): + environment_agents=None, environment_params=None, **kwargs): if topology is None: topology = utils.load_network(network_params, diff --git a/soil/web/server.py b/soil/web/__init__.py similarity index 67% rename from soil/web/server.py rename to soil/web/__init__.py index e6d2695..97902d9 100644 --- a/soil/web/server.py +++ b/soil/web/__init__.py @@ -1,8 +1,10 @@ import io +import threading +import asyncio import logging import networkx as nx import os -import threading +import sys import tornado.ioloop import tornado.web import tornado.websocket @@ -14,9 +16,20 @@ from contextlib import contextmanager from time import sleep from xml.etree.ElementTree import tostring +from tornado.concurrent import run_on_executor +from concurrent.futures import ThreadPoolExecutor + +from ..simulation import SoilSimulation logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) +ROOT = os.path.abspath(os.path.dirname(__file__)) + +MAX_WORKERS = 4 +LOGGING_INTERVAL = 0.5 + +# Workaround to let Soil load the required modules +sys.path.append(ROOT) class PageHandler(tornado.web.RequestHandler): """ Handler for the HTML template which holds the visualization. """ @@ -28,6 +41,7 @@ class PageHandler(tornado.web.RequestHandler): class SocketHandler(tornado.websocket.WebSocketHandler): """ Handler for websocket. """ + executor = ThreadPoolExecutor(max_workers=MAX_WORKERS) def open(self): if self.application.verbose: @@ -55,9 +69,10 @@ class SocketHandler(tornado.websocket.WebSocketHandler): self.write_message({'type': 'error', 'error': error}) return - else: - self.config = self.config[0] - self.send_log('INFO.' + self.application.simulator.name, 'Using config: {name}'.format(name=self.config['name'])) + + self.config = self.config[0] + self.send_log('INFO.' + self.simulation_name, + 'Using config: {name}'.format(name=self.config['name'])) if 'visualization_params' in self.config: self.write_message({'type': 'visualization_params', @@ -91,17 +106,17 @@ class SocketHandler(tornado.websocket.WebSocketHandler): logger.info('Trial {} requested!'.format(msg['data'])) self.send_log('INFO.' + __name__, 'Trial {} requested!'.format(msg['data'])) self.write_message({'type': 'get_trial', - 'data': self.get_trial( int(msg['data']) ) }) + 'data': self.get_trial(int(msg['data']))}) elif msg['type'] == 'run_simulation': if self.application.verbose: logger.info('Running new simulation for {name}'.format(name=self.config['name'])) - self.send_log('INFO.' + self.application.simulator.name, 'Running new simulation for {name}'.format(name=self.config['name'])) + self.send_log('INFO.' + self.simulation_name, 'Running new simulation for {name}'.format(name=self.config['name'])) self.config['environment_params'] = msg['data'] self.run_simulation() elif msg['type'] == 'download_gexf': - G = self.simulation[ int(msg['data']) ].history_to_graph() + G = self.trials[ int(msg['data']) ].history_to_graph() for node in G.nodes(): if 'pos' in G.node[node]: G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}} @@ -113,7 +128,7 @@ class SocketHandler(tornado.websocket.WebSocketHandler): 'data': tostring(writer.xml).decode(writer.encoding) }) elif msg['type'] == 'download_json': - G = self.simulation[ int(msg['data']) ].history_to_graph() + G = self.trials[ int(msg['data']) ].history_to_graph() for node in G.nodes(): if 'pos' in G.node[node]: G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}} @@ -130,13 +145,13 @@ class SocketHandler(tornado.websocket.WebSocketHandler): try: if (not self.log_capture_string.closed and self.log_capture_string.getvalue()): for i in range(len(self.log_capture_string.getvalue().split('\n')) - 1): - self.send_log('INFO.' + self.application.simulator.name, self.log_capture_string.getvalue().split('\n')[i]) + self.send_log('INFO.' + self.simulation_name, self.log_capture_string.getvalue().split('\n')[i]) self.log_capture_string.truncate(0) self.log_capture_string.seek(0) finally: if self.capture_logging: - thread = threading.Timer(0.01, self.update_logging) - thread.start() + tornado.ioloop.IOLoop.current().call_later(LOGGING_INTERVAL, self.update_logging) + def on_close(self): if self.application.verbose: @@ -144,29 +159,45 @@ class SocketHandler(tornado.websocket.WebSocketHandler): def send_log(self, logger, logging): self.write_message({'type': 'log', - 'logger': logger, - 'logging': logging }) + 'logger': logger, + 'logging': logging}) + + @property + def simulation_name(self): + return self.config.get('name', 'NoSimulationRunning') + + @run_on_executor + def nonblocking(self, config): + simulation = SoilSimulation(**config) + return simulation.run() + @tornado.gen.coroutine def run_simulation(self): # Run simulation and capture logs + logger.info('Running simulation!') if 'visualization_params' in self.config: del self.config['visualization_params'] - with self.logging(self.application.simulator.name): + with self.logging(self.simulation_name): try: - self.simulation = self.application.simulator.run(self.config) - trials = [] - for i in range(self.config['num_trials']): - trials.append('{}_trial_{}'.format(self.name, i)) + config = dict(**self.config) + config['dir_path'] = os.path.join(self.application.dir_path, config['name']) + config['dump'] = self.application.dump + self.trials = yield self.nonblocking(config) + self.write_message({'type': 'trials', - 'data': trials }) - except: - error = 'Something went wrong. Please, try again.' + 'data': list(trial.name for trial in self.trials) }) + except Exception as ex: + error = 'Something went wrong:\n\t{}'.format(ex) + logging.info(error) self.write_message({'type': 'error', - 'error': error}) - self.send_log('ERROR.' + self.application.simulator.name, error) + 'error': error}) + self.send_log('ERROR.' + self.simulation_name, error) def get_trial(self, trial): - G = self.simulation[trial].history_to_graph() + logger.info('Available trials: %s ' % len(self.trials)) + logger.info('Ask for : %s' % trial) + trial = self.trials[trial] + G = trial.history_to_graph() return nx.node_link_data(G) @contextmanager @@ -193,19 +224,20 @@ class ModularServer(tornado.web.Application): page_handler = (r'/', PageHandler) socket_handler = (r'/ws', SocketHandler) static_handler = (r'/(.*)', tornado.web.StaticFileHandler, - {'path': 'templates'}) + {'path': os.path.join(ROOT, 'static')}) local_handler = (r'/local/(.*)', tornado.web.StaticFileHandler, {'path': ''}) handlers = [page_handler, socket_handler, static_handler, local_handler] settings = {'debug': True, - 'template_path': os.path.dirname(__file__) + '/templates'} + 'template_path': ROOT + '/templates'} - def __init__(self, simulator, name='SOIL', verbose=True, *args, **kwargs): + def __init__(self, dump=False, dir_path='output', name='SOIL', verbose=True, *args, **kwargs): self.verbose = verbose self.name = name - self.simulator = simulator + self.dump = dump + self.dir_path = dir_path # Initializing the application itself: super().__init__(self.handlers, **self.settings) @@ -220,3 +252,23 @@ class ModularServer(tornado.web.Application): self.listen(self.port) # webbrowser.open(url) tornado.ioloop.IOLoop.instance().start() + + +def run(*args, **kwargs): + asyncio.set_event_loop(asyncio.new_event_loop()) + server = ModularServer(*args, **kwargs) + server.launch() + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='Visualization of a Graph Model') + + parser.add_argument('--name', '-n', nargs=1, default='SOIL', help='name of the simulation') + parser.add_argument('--dump', '-d', help='dumping results in folder output', action='store_true') + parser.add_argument('--port', '-p', nargs=1, default=8001, help='port for launching the server') + parser.add_argument('--verbose', '-v', help='verbose mode', action='store_true') + args = parser.parse_args() + + run(name=args.name, port=(args.port[0] if isinstance(args.port, list) else args.port), verbose=args.verbose) diff --git a/soil/web/__main__.py b/soil/web/__main__.py new file mode 100644 index 0000000..5c211a8 --- /dev/null +++ b/soil/web/__main__.py @@ -0,0 +1,5 @@ +from . import main + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/soil/web/simulator.py b/soil/web/simulator.py deleted file mode 100644 index 5ff9009..0000000 --- a/soil/web/simulator.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -import networkx as nx -from soil.simulation import SoilSimulation - - -class Simulator(): - """ Simulator for running simulations. Using SOIL.""" - - def __init__(self, dump=False, dir_path='output'): - self.name = 'soil' - self.dump = dump - self.dir_path = dir_path - - def run(self, config): - name = config['name'] - print('Using config(s): {name}'.format(name=name)) - - sim = SoilSimulation(**config) - sim.dir_path = os.path.join(self.dir_path, name) - sim.dump = self.dump - - print('Dumping results to {} : {}'.format(sim.dir_path, sim.dump)) - - simulation_results = sim.run_simulation() - - # G = simulation_results[0].history_to_graph() - # for node in G.nodes(): - # if 'pos' in G.node[node]: - # G.node[node]['viz'] = {"position": {"x": G.node[node]['pos'][0], "y": G.node[node]['pos'][1], "z": 0.0}} - # del (G.node[node]['pos']) - # nx.write_gexf(G, 'test.gexf', version='1.2draft') - - return simulation_results - - def reset(self): - pass diff --git a/soil/web/templates/css/main.css b/soil/web/static/css/main.css similarity index 100% rename from soil/web/templates/css/main.css rename to soil/web/static/css/main.css diff --git a/soil/web/templates/css/timeline.css b/soil/web/static/css/timeline.css similarity index 100% rename from soil/web/templates/css/timeline.css rename to soil/web/static/css/timeline.css diff --git a/soil/web/templates/img/background/map.png b/soil/web/static/img/background/map.png similarity index 100% rename from soil/web/templates/img/background/map.png rename to soil/web/static/img/background/map.png diff --git a/soil/web/templates/img/background/map_4800x2860.jpg b/soil/web/static/img/background/map_4800x2860.jpg similarity index 100% rename from soil/web/templates/img/background/map_4800x2860.jpg rename to soil/web/static/img/background/map_4800x2860.jpg diff --git a/soil/web/templates/img/logo_gsi.svg b/soil/web/static/img/logo_gsi.svg similarity index 100% rename from soil/web/templates/img/logo_gsi.svg rename to soil/web/static/img/logo_gsi.svg diff --git a/soil/web/templates/img/logo_soil.png b/soil/web/static/img/logo_soil.png similarity index 100% rename from soil/web/templates/img/logo_soil.png rename to soil/web/static/img/logo_soil.png diff --git a/soil/web/templates/img/svg/home.svg b/soil/web/static/img/svg/home.svg similarity index 100% rename from soil/web/templates/img/svg/home.svg rename to soil/web/static/img/svg/home.svg diff --git a/soil/web/templates/img/svg/person.svg b/soil/web/static/img/svg/person.svg similarity index 100% rename from soil/web/templates/img/svg/person.svg rename to soil/web/static/img/svg/person.svg diff --git a/soil/web/templates/img/svg/plus.svg b/soil/web/static/img/svg/plus.svg similarity index 100% rename from soil/web/templates/img/svg/plus.svg rename to soil/web/static/img/svg/plus.svg diff --git a/soil/web/templates/img/svg/target.svg b/soil/web/static/img/svg/target.svg similarity index 100% rename from soil/web/templates/img/svg/target.svg rename to soil/web/static/img/svg/target.svg diff --git a/soil/web/templates/img/svg/time.svg b/soil/web/static/img/svg/time.svg similarity index 100% rename from soil/web/templates/img/svg/time.svg rename to soil/web/static/img/svg/time.svg diff --git a/soil/web/templates/js/socket.js b/soil/web/static/js/socket.js similarity index 90% rename from soil/web/templates/js/socket.js rename to soil/web/static/js/socket.js index a490116..5aa8fe2 100755 --- a/soil/web/templates/js/socket.js +++ b/soil/web/static/js/socket.js @@ -239,17 +239,19 @@ var reset_configuration = function() { $('#download_json').off(); } +var slider; + var set_timeline = function(graph) { // 'Timeline' slider var [min, max] = get_limits(graph); - var stepUnix = (max - min) / 200; + var stepUnix = 1; var minUnix = (min !== Math.min()) ? min : 0; var maxUnix = (max !== Math.max()) ? max : minUnix + 20; slider = d3.slider(); d3.select('#slider3').attr('width', width).call( - slider.axis(true).min(minUnix).max(maxUnix).step(stepUnix).value(maxUnix) + slider.axis(true).min(minUnix).max(maxUnix).step(stepUnix).value(minUnix) .on('slide', function(evt, value) { self.GraphVisualization.update_graph($('.config-item #properties').val(), value, function() { update_statistics_table(); @@ -281,65 +283,64 @@ var set_timeline = function(graph) { // Button 'Play' $('button#button_play').on('click', function() { + play(); - $('button#button_play').addClass('pressed').prop("disabled", true); - $('#speed-slider').slider('disable'); - slider.step( 1 / speed ); - - if (slider.value() >= maxUnix) { - slider.value(minUnix); - self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function() { - update_statistics_table(); - }); - setTimeout(player, 1000); - } else { - player(); - } - - var i = slider.value(); - function player() { - clearInterval(play); - play = setInterval(function() { - self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function() { - update_statistics_table(); - }); - - if (slider.value() + slider.step() >= maxUnix) { - slider.value(maxUnix); - slider.step(stepUnix); - clearInterval(play); - $('button#button_play').removeClass('pressed').prop("disabled", false); - $('#speed-slider').slider('enable'); - } else { - slider.value(i); - i += slider.step(); - } - - }, 5); - } }); // Button 'Pause' $('button#button_pause').on('click', function() { - clearInterval(play); - slider.step(stepUnix); + stop(); $('button#button_play').removeClass('pressed').prop("disabled", false); - $('#speed-slider').slider('enable'); }); // Button 'Zoom to Fit' $('button#button_zoomFit').click(function() { self.GraphVisualization.fit(); }); } +var player; + +function play(){ + $('button#button_play').addClass('pressed').prop("disabled", true); + + if (slider.value() >= slider.max()) { + slider.value(slider.min()); + } + + var FRAME_INTERVAL = 100; + var speed_ratio = FRAME_INTERVAL / 1000 // speed=1 => 1 step per second + + nextStep = function() { + newvalue = Math.min(slider.value() + speed*speed_ratio, slider.max()); + console.log("new time value", newvalue); + slider.value(newvalue); + + self.GraphVisualization.update_graph($('.config-item #properties').val(), slider.value(), function () { + update_statistics_table(); + }); + + if (newvalue < slider.max()) { + player = setTimeout(nextStep, FRAME_INTERVAL); + } else { + $('button#button_play').removeClass('pressed').prop("disabled", false); + } + } + + player = setTimeout(nextStep, FRAME_INTERVAL); +} + +function stop() { + clearTimeout(player); +} + var reset_timeline = function() { // 'Timeline' slider $('#slider3').html(''); // 'Speed' slider - $('#speed-slider').slider('disable').slider('setValue', 1000); + // $('#speed-slider').slider('disable').slider('setValue', 1000); // Buttons - clearInterval(play); + stop(); $('button#button_play').off().removeClass('pressed').prop("disabled", false); $('button#button_pause').off(); $('button#button_zoomFit').off(); diff --git a/soil/web/templates/js/template.js b/soil/web/static/js/template.js similarity index 100% rename from soil/web/templates/js/template.js rename to soil/web/static/js/template.js diff --git a/soil/web/templates/js/timeline.js b/soil/web/static/js/timeline.js similarity index 100% rename from soil/web/templates/js/timeline.js rename to soil/web/static/js/timeline.js diff --git a/soil/web/templates/js/visualization.js b/soil/web/static/js/visualization.js similarity index 100% rename from soil/web/templates/js/visualization.js rename to soil/web/static/js/visualization.js diff --git a/soil/web/templates/index.html b/soil/web/templates/index.html index eb88b6b..13a373c 100644 --- a/soil/web/templates/index.html +++ b/soil/web/templates/index.html @@ -41,7 +41,7 @@ var width = window.innerWidth * 0.75, height = window.innerHeight * 3 / 5, - speed = 1000, + speed = 1, play, slider; @@ -255,7 +255,7 @@ max
- +