diff --git a/PONS.ipynb b/PONS.ipynb index 863c01d..26d3aa8 100644 --- a/PONS.ipynb +++ b/PONS.ipynb @@ -16,13 +16,26 @@ "parameters" ] }, - "outputs": [], + "outputs": [ + { + "ename": "ModuleNotFoundError", + "evalue": "No module named 'matplotlib'", + "output_type": "error", + "traceback": [ + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mModuleNotFoundError\u001B[0m Traceback (most recent call last)", + "Cell \u001B[0;32mIn[1], line 10\u001B[0m\n\u001B[1;32m 8\u001B[0m \u001B[38;5;28;01mimport\u001B[39;00m \u001B[38;5;21;01mpandas\u001B[39;00m \u001B[38;5;28;01mas\u001B[39;00m \u001B[38;5;21;01mpd\u001B[39;00m\n\u001B[1;32m 9\u001B[0m \u001B[38;5;28;01mimport\u001B[39;00m \u001B[38;5;21;01mnumpy\u001B[39;00m \u001B[38;5;28;01mas\u001B[39;00m \u001B[38;5;21;01mnp\u001B[39;00m\n\u001B[0;32m---> 10\u001B[0m \u001B[38;5;28;01mimport\u001B[39;00m \u001B[38;5;21;01mmatplotlib\u001B[39;00m\u001B[38;5;21;01m.\u001B[39;00m\u001B[38;5;21;01mpyplot\u001B[39;00m \u001B[38;5;28;01mas\u001B[39;00m \u001B[38;5;21;01mplt\u001B[39;00m\n\u001B[1;32m 11\u001B[0m \u001B[38;5;28;01mimport\u001B[39;00m \u001B[38;5;21;01mseaborn\u001B[39;00m \u001B[38;5;28;01mas\u001B[39;00m \u001B[38;5;21;01msns\u001B[39;00m\n\u001B[1;32m 13\u001B[0m \u001B[38;5;28;01mimport\u001B[39;00m \u001B[38;5;21;01mpons\u001B[39;00m\n", + "\u001B[0;31mModuleNotFoundError\u001B[0m: No module named 'matplotlib'" + ] + } + ], "source": [ "import random\n", "import math\n", "import time\n", "import simpy\n", "import json\n", + "\n", "import copy\n", "import pandas as pd\n", "import numpy as np\n", diff --git a/pons/__init__.py b/pons/__init__.py index bae4bdb..8858f22 100644 --- a/pons/__init__.py +++ b/pons/__init__.py @@ -1,4 +1,5 @@ from .simulation import NetSim +from .events import EventManager, Event, EventType from .mobility import generate_randomwaypoint_movement, OneMovement, OneMovementManager from .node import generate_nodes, Node, NetworkSettings, Message, BROADCAST_ADDR from .routing import Router, EpidemicRouter diff --git a/pons/events.py b/pons/events.py new file mode 100644 index 0000000..c5b6ec8 --- /dev/null +++ b/pons/events.py @@ -0,0 +1,96 @@ +from enum import Enum +from typing import List, Dict, Set +import pons +import simpy + + +class EventType(Enum): + RECEIVED = 0 + DELIVERED = 1 + CREATED = 2 + CONNECTION_UP = 3 + CONNECTION_DOWN = 4 + DROPPED = 5 + RELAY_STARTED = 6 + + +class Event: + """Simulation Events""" + + def __init__(self, type: EventType, node: int, from_node: int, message: "pons.Message", time: float): + self.type: EventType = type + self.node: int = node + self.from_node: int = from_node + self.message: pons.Message = message + self.time: float = time + + def __str__(self): + if self.type == EventType.RECEIVED: + return f"{self.time}: Message relayed {self.from_node} <-> {self.node} {self.message.id}" + if self.type == EventType.DELIVERED: + return f"{self.time}: Message delivered {self.from_node} <-> {self.node} {self.message.id}" + if self.type == EventType.CREATED: + return f"{self.time}: Message created {self.node} {self.message.id}" + if self.type == EventType.CONNECTION_UP: + return f"{self.time}: Connection UP {self.node} <-> {self.from_node}" + if self.type == EventType.CONNECTION_DOWN: + return f"{self.time}: Connection DOWN {self.node} <-> {self.from_node}" + if self.type == EventType.DROPPED: + return f"{self.time}: Message dropped {self.node} {self.message.id}" + if self.type == EventType.RELAY_STARTED: + return f"{self.time}: Message relay started {self.node} {self.message.id}" + + return "" + + def __eq__(self, other): + if not isinstance(other, Event): + return False + if other.type != self.type: + return False + if other.time != self.time: + return False + if self.type in [EventType.RECEIVED, EventType.DELIVERED]: + return self.node == other.node and self.from_node == other.from_node and self.message == other.message + if self.type in [EventType.CREATED, EventType.DROPPED, EventType.RELAY_STARTED]: + return self.node == other.node and self.message == other.message + return self.node == other.node and self.from_node == other.from_node or self.node == other.from_node and self.from_node == other.node + + def __hash__(self): + return self.type.value + self.node + self.from_node + hash(self.message) + int(self.time) + + +class EventManager: + def __init__(self, env: simpy.Environment, nodes: "List[pons.Node]"): + self._env: simpy.Environment = env + self.events: Set[Event] = set() + self._last_peers: Dict[int, Set[int]] = {node.id: set() for node in nodes} + self._current_peers: Dict[int, Set[int]] = {node.id: set() for node in nodes} + + def on_message_received(self, node: int, from_node: int, msg: "pons.Message"): + self.events.add(Event(EventType.RECEIVED, node, from_node, msg, self._env.now)) + + def on_message_delivered(self, node: int, from_node: int, msg: "pons.Message"): + self.events.add(Event(EventType.DELIVERED, node, from_node, msg, self._env.now)) + + def on_message_created(self, node: int, msg: "pons.Message"): + self.events.add(Event(EventType.CREATED, node, node, msg, self._env.now)) + + def on_message_dropped(self, node: int, msg: "pons.Message"): + self.events.add(Event(EventType.DROPPED, node, node, msg, self._env.now)) + + def on_peer_discovery(self, node1: int, node2: int): + if node2 not in self._last_peers[node1] and node1 not in self._last_peers[node2]: + self.events.add(Event(EventType.CONNECTION_UP, node1, node2, None, self._env.now)) + self._current_peers[node1].add(node2) + self._current_peers[node2].add(node1) + + def on_before_scan(self, node: int): + lost_peers = [peer for peer in self._last_peers[node] if peer not in self._current_peers[node]] + events = [Event(EventType.CONNECTION_DOWN, node, peer, None, self._env.now) for peer in lost_peers] + for event in events: + self.events.add(event) + self._last_peers[node] = self._current_peers[node].copy() + self._current_peers[node].clear() + for peer in lost_peers: + if node in self._current_peers[peer]: + self._current_peers[peer].remove(node) diff --git a/pons/routing/directdelivery.py b/pons/routing/directdelivery.py index f823960..63f570c 100644 --- a/pons/routing/directdelivery.py +++ b/pons/routing/directdelivery.py @@ -8,11 +8,6 @@ def __init__(self, scan_interval=2.0, capacity=0): def __str__(self): return "DirectDeliveryRouter" - def add(self, msg): - # print("adding new msg to store") - if self.store_add(msg): - self.forward(msg) - def forward(self, msg): if msg.dst in self.peers and not self.msg_already_spread(msg, msg.dst): # self.log("sending directly to receiver") @@ -25,24 +20,6 @@ def forward(self, msg): def on_peer_discovered(self, peer_id): # self.log("peer discovered: %d" % peer_id) + super().on_peer_discovered(peer_id) for msg in self.store: self.forward(msg) - - def on_msg_received(self, msg, remote_id): - # self.log("msg received: %s from %d" % (msg, remote_id)) - self.netsim.routing_stats['relayed'] += 1 - if not self.is_msg_known(msg): - self.remember(remote_id, msg) - msg.hops += 1 - self.store_add(msg) - if msg.dst == self.my_id: - # self.log("msg arrived", self.my_id) - self.netsim.routing_stats['delivered'] += 1 - self.netsim.routing_stats['hops'] += msg.hops - self.netsim.routing_stats['latency'] += self.env.now - msg.created - else: - # self.log("msg not arrived yet", self.my_id) - self.forward(msg) - else: - # self.log("msg already known", self.history) - self.netsim.routing_stats['dups'] += 1 diff --git a/pons/routing/epidemic.py b/pons/routing/epidemic.py index 2aa8079..91717f9 100644 --- a/pons/routing/epidemic.py +++ b/pons/routing/epidemic.py @@ -8,11 +8,6 @@ def __init__(self, scan_interval=2.0, capacity=0): def __str__(self): return "EpidemicRouter" - def add(self, msg): - # self.log("adding new msg to store") - if self.store_add(msg): - self.forward(msg) - def forward(self, msg): if msg.dst in self.peers and not self.msg_already_spread(msg, msg.dst): # self.log("sending directly to receiver") @@ -35,27 +30,9 @@ def forward(self, msg): def on_peer_discovered(self, peer_id): # self.log("peer discovered: %d" % peer_id) + super().on_peer_discovered(peer_id) for msg in self.store: if msg.is_expired(self.netsim.env.now): self.store_del(msg) else: self.forward(msg) - - def on_msg_received(self, msg, remote_id): - # self.log("msg received: %s from %d" % (msg, remote_id)) - self.netsim.routing_stats['relayed'] += 1 - if not self.is_msg_known(msg): - self.remember(remote_id, msg) - msg.hops += 1 - self.store_add(msg) - if msg.dst == self.my_id: - # self.log("msg arrived %s" % msg) - self.netsim.routing_stats['delivered'] += 1 - self.netsim.routing_stats['hops'] += msg.hops - self.netsim.routing_stats['latency'] += self.env.now - msg.created - else: - # self.log("msg not arrived yet", self.my_id) - self.forward(msg) - else: - # self.log("msg already known", self.history) - self.netsim.routing_stats['dups'] += 1 diff --git a/pons/routing/firstcontact.py b/pons/routing/firstcontact.py index c2c9122..75be5c2 100644 --- a/pons/routing/firstcontact.py +++ b/pons/routing/firstcontact.py @@ -9,11 +9,6 @@ def __init__(self, scan_interval=2.0): def __str__(self): return "FirstContactRouter" - def add(self, msg): - # print("adding new msg to store") - if self.store_add(msg): - self.forward(msg) - def forward(self, msg): if msg.dst in self.peers and not self.msg_already_spread(msg, msg.dst): # self.log("sending directly to receiver") @@ -38,24 +33,6 @@ def forward(self, msg): def on_peer_discovered(self, peer_id): # self.log("peer discovered: %d" % peer_id) + super().on_peer_discovered(peer_id) for msg in self.store: self.forward(msg) - - def on_msg_received(self, msg, remote_id): - # self.log("msg received: %s from %d" % (msg, remote_id)) - self.netsim.routing_stats['relayed'] += 1 - if not self.is_msg_known(msg): - self.remember(remote_id, msg) - msg.hops += 1 - self.store_add(msg) - if msg.dst == self.my_id: - # self.log("msg arrived", self.my_id) - self.netsim.routing_stats['delivered'] += 1 - self.netsim.routing_stats['hops'] += msg.hops - self.netsim.routing_stats['latency'] += self.env.now - msg.created - else: - # self.log("msg not arrived yet", self.my_id) - self.forward(msg) - else: - # self.log("msg already known", self.history) - self.netsim.routing_stats['dups'] += 1 diff --git a/pons/routing/router.py b/pons/routing/router.py index a5b208f..ddd0906 100644 --- a/pons/routing/router.py +++ b/pons/routing/router.py @@ -1,5 +1,3 @@ - -from copy import copy import pons HELLO_MSG_SIZE = 42 @@ -25,7 +23,11 @@ def log(self, msg): print("[%s : %s] %s" % (self.my_id, self, msg)) def add(self, msg: pons.Message): - self.store_add(msg) + if self.store_add(msg): + self.forward(msg) + + def forward(self, msg): + pass def store_add(self, msg: pons.Message): if self.capacity > 0 and self.used + msg.size > self.capacity: @@ -38,11 +40,13 @@ def store_add(self, msg: pons.Message): # self.log("store cleaned up, made room for msg %s" % msg.id) self.store.append(msg) self.used += msg.size + self.netsim.event_manager.on_message_created(self.my_id, msg) return True def store_del(self, msg: pons.Message): self.used -= msg.size self.store.remove(msg) + self.netsim.event_manager.on_message_dropped(self.my_id, msg) def store_cleanup(self): # [self.store_del(msg) @@ -81,6 +85,7 @@ def scan(self): # self.on_peer_discovered(peer) # do actual peer discovery with a hello message + self.netsim.event_manager.on_before_scan(self.my_id) self.peers.clear() self.netsim.nodes[self.my_id].send(self.netsim, pons.BROADCAST_ADDR, pons.Message( "HELLO", self.my_id, pons.BROADCAST_ADDR, HELLO_MSG_SIZE, self.netsim.env.now)) @@ -98,10 +103,28 @@ def on_scan_received(self, msg: pons.Message, remote_id: int): # self.log("DUP PEER: %d" % remote_id) def on_peer_discovered(self, peer_id): - self.log("peer discovered: %d" % peer_id) + #self.log("peer discovered: %d" % peer_id) + self.netsim.event_manager.on_peer_discovery(self.my_id, peer_id) def on_msg_received(self, msg: pons.Message, remote_id: int): - self.log("msg received: %s from %d" % (msg, remote_id)) + self.netsim.routing_stats['relayed'] += 1 + if not self.is_msg_known(msg): + self.remember(remote_id, msg) + msg.hops += 1 + self.store_add(msg) + if msg.dst == self.my_id: + # print("msg arrived", self.my_id) + self.netsim.routing_stats['delivered'] += 1 + self.netsim.routing_stats['hops'] += msg.hops + self.netsim.routing_stats['latency'] += self.env.now - msg.created + self.netsim.event_manager.on_message_delivered(self.my_id, remote_id, msg) + else: + # print("msg not arrived yet", self.my_id) + self.netsim.event_manager.on_message_received(self.my_id, remote_id, msg) + self.forward(msg) + else: + # print("msg already known", self.history) + self.netsim.routing_stats['dups'] += 1 def remember(self, peer_id, msg: pons.Message): if msg.id not in self.history: diff --git a/pons/routing/sprayandwait.py b/pons/routing/sprayandwait.py index 1b24332..2edffed 100644 --- a/pons/routing/sprayandwait.py +++ b/pons/routing/sprayandwait.py @@ -19,8 +19,7 @@ def __str__(self): def add(self, msg): # print("adding new msg to store") msg.metadata['copies'] = self.copies - if self.store_add(msg): - self.forward(msg) + super().add(msg) def forward(self, msg): if msg.dst in self.peers and not self.msg_already_spread(msg, msg.dst): @@ -54,25 +53,6 @@ def forward(self, msg): self.remember(peer, msg) def on_peer_discovered(self, peer_id): - # self.log("peer discovered: %d" % peer_id) + super().on_peer_discovered(peer_id) for msg in self.store: self.forward(msg) - - def on_msg_received(self, msg, remote_id): - # self.log("msg received: %s from %d" % (msg, remote_id)) - self.netsim.routing_stats['relayed'] += 1 - if not self.is_msg_known(msg): - self.remember(remote_id, msg) - msg.hops += 1 - self.store_add(msg) - if msg.dst == self.my_id: - # print("msg arrived", self.my_id) - self.netsim.routing_stats['delivered'] += 1 - self.netsim.routing_stats['hops'] += msg.hops - self.netsim.routing_stats['latency'] += self.env.now - msg.created - else: - # print("msg not arrived yet", self.my_id) - self.forward(msg) - else: - # print("msg already known", self.history) - self.netsim.routing_stats['dups'] += 1 diff --git a/pons/simulation.py b/pons/simulation.py index 4a779d4..0112bff 100644 --- a/pons/simulation.py +++ b/pons/simulation.py @@ -1,5 +1,8 @@ -import simpy import time +from typing import List, Dict + +import simpy + import pons @@ -21,6 +24,7 @@ def __init__(self, duration, world, nodes, movements=[], msggens=None, config=No 'latency': 0.0, 'started': 0, 'relayed': 0, 'removed': 0, 'aborted': 0, 'dups': 0, 'latency_avg': 0.0, 'delivery_prob': 0.0, 'hops_avg': 0.0, 'overhead_ratio': 0.0} self.router_stats = {} + self.event_manager: pons.EventManager = pons.EventManager(self.env, nodes) self.mover = pons.OneMovementManager( self.env, self.nodes, self.movements) @@ -107,14 +111,14 @@ def run(self): if self.routing_stats["delivered"] > 0: self.routing_stats["latency_avg"] = self.routing_stats["latency"] / \ - self.routing_stats["delivered"] + self.routing_stats["delivered"] self.routing_stats["hops_avg"] = self.routing_stats["hops"] / \ - self.routing_stats["delivered"] + self.routing_stats["delivered"] self.routing_stats["overhead_ratio"] = (self.routing_stats["relayed"] - self.routing_stats["delivered"]) / \ - self.routing_stats["delivered"] + self.routing_stats["delivered"] self.routing_stats["delivery_prob"] = self.routing_stats["delivered"] / \ - self.routing_stats["created"] + self.routing_stats["created"] # delete entry "hops" and "latency" from routing_stats as they are only used for calculating the average del self.routing_stats["hops"] diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..158591e --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,4 @@ +from .components.layout import layout_component +from .config import config, ROUTERS +from .data import DataManager +from .visualization import Visualization diff --git a/ui/app.py b/ui/app.py new file mode 100644 index 0000000..1906590 --- /dev/null +++ b/ui/app.py @@ -0,0 +1,414 @@ +from typing import Tuple + +import dash_bootstrap_components as dbc +from dash import Dash, html, no_update, Input, Output, ctx, State + +from ui import Visualization, DataManager, layout_component, config, ROUTERS + +settings = { + "SIM_TIME": 3600, + "WORLD_SIZE": (1000, 1000), # fixed value + "NUM_NODES": 10, + "MIN_SPEED": 1., + "MAX_SPEED": 5., + "NET_RANGE": 50, + "SIZEREF": 605. / 1000., + "FRAME_STEP": 1, + "ROUTER": ROUTERS["EpidemicRouter"], + "ROUTER_NAME": "EpidemicRouter", + "SHOW_NODE_IDS": True, + "MESSAGES": { + "MIN_INTERVAL": 30, + "MAX_INTERVAL": 30, + "MIN_SIZE": 0, + "MAX_SIZE": 1028, + "MIN_TTL": 3200, + "MAX_TTL": 3600 + } +} + +data_manager = DataManager(settings) +visualization = Visualization(data_manager, settings) +fig = visualization.get_figure() +app = Dash(__name__, external_stylesheets=[dbc.themes.COSMO, dbc.icons.BOOTSTRAP]) + +app.layout = layout_component(fig, settings) + +# region Callbacks + +# js code getting width and height of plot +app.clientside_callback( + """ + async function getWidth (w, h) { + var delay = ms => new Promise(res => setTimeout(res, ms)); + await delay(200); + var plot = document.getElementsByClassName('bglayer')[0]; + if (plot == null) { return '0;0' }; + var rect = plot.getBoundingClientRect(); + return rect.width.toString() + ';' + rect.height.toString();; + } + """, + Output('size_helper', 'children', allow_duplicate=True), + [Input("breakpoints", "width"), Input("breakpoints", "height")], + prevent_initial_call=True +) + + +@app.callback( + Output("checkbox_collapse", "is_open"), + [Input("checkbox_collapse_button", "n_clicks")], + [State("checkbox_collapse", "is_open")], +) +def toggle_collapse(n_clicks, is_open): + """ + toggling the checkbox collapse + @param n_clicks: n_clicks of checkbox_collapse_button + @param is_open: state of checkbox_collapse is_open property + @return: toggled value for is_open + """ + if n_clicks: + return not is_open + return is_open + + +@app.callback( + [ + Output("plot", "figure", allow_duplicate=True), + Output("anim_slider", "value", allow_duplicate=True), + Output("event_div", "children", allow_duplicate=True), + Output("buffer_div", "children", allow_duplicate=True) + ], + [ + Input("anim_slider", "value"), + Input("anim_slider", "drag_value"), + Input("anim_interval", "n_intervals") + ], + prevent_initial_call=True +) +def animate(slider_value, drag_value, n_intervals): + """ + handles all animations + @param slider_value: value of the animation slider + @param drag_value: drag value of the animation slider + @param n_intervals: current animation interval value + @return: tuple consisting of the plot figure, + the new slider value, the event_div content and the buffer_div content + """ + # if slider value has been triggered + if "anim_slider.value" in ctx.triggered_prop_ids: + # if visualization is currently waiting because of drag_value + if visualization.is_waiting(): + # start to play + visualization.play() + # set the visualization frame to the slider value + visualization.set_frame(int(slider_value)) + return _get_animation_outputs() + # else if visualization is currently not playing, do not change anything + if not visualization.is_playing(): + return _get_animation_outputs() + # else if drag value is triggered and it differs from the slider value + if "anim_slider.drag_value" in ctx.triggered_prop_ids and slider_value != drag_value: + # wait and do not change anything + visualization.wait() + return no_update + # if the interval value has changed + if "anim_interval.n_intervals" in ctx.triggered_prop_ids: + if n_intervals is None: + return no_update + # update the frame + visualization.update_frame() + return _get_animation_outputs() + + return no_update + + +def _get_animation_outputs() -> Tuple: + """ + returns the animation outputs as a tuple + """ + frame = visualization.get_frame() + return ( + # plot figure + visualization.get_figure(), + # current frame + frame, + # event_div content + [html.Div([str(e)]) for e in data_manager.get_events(frame)], + # buffer_div content + [html.Div([html.Div([key], className="mt-2 mr-2"), + dbc.Progress(value=value, className="mt-3 buffer w-100")], + className="d-flex justify-content-between") for key, value in + data_manager.get_buffer(frame).items()] + ) + + +@app.callback( + Output("playIcon", "className", allow_duplicate=True), + Input("playButton", "n_clicks"), + prevent_initial_call=True +) +def on_play_click(n_clicks): + """ + handling the play click + @param n_clicks: n_clicks of playButton + @return: new playIcon class + """ + if visualization.is_playing(): + visualization.pause() + return "bi bi-play" + visualization.play() + return "bi bi-pause" + + +@app.callback( + Output("speed_slider", "value"), + Input("speed_slider", "drag_value"), + prevent_initial_call=True +) +def on_speed_drag(value): + """ + handling the speed slider + @param value: the drag_value of the speed slider + @return: the slider value of the speed slider + """ + settings["FRAME_STEP"] = value + visualization.set_settings(settings) + return no_update + + +@app.callback( + [ + Output("plot", "figure", allow_duplicate=True), + Output("anim_slider", "value", allow_duplicate=True), + Output("event_div", "children", allow_duplicate=True), + Output("buffer_div", "children", allow_duplicate=True) + ], + Input("stepButton", "n_clicks"), + prevent_initial_call=True +) +def on_step_click(n_clicks): + """ + handles a click on the step button + @param n_clicks: n_clicks of the stepButton + @return: tuple consisting of the plot figure, + the new slider value, the event_div content and the buffer_div content + """ + frame = (visualization.get_frame() + settings["FRAME_STEP"]) % settings["SIM_TIME"] + visualization.set_frame(frame) + return _get_animation_outputs() + + +@app.callback( + Output("num_nodes_display", "children"), + Input("num_nodes", "value"), + prevent_initial_call=True +) +def on_num_nodes(value): + """ + forwards input change of num_nodes slider to the display + @param value: the slider value of num_nodes + @return: the value for the display + """ + return value + + +@app.callback( + Output("net_range_display", "children"), + Input("net_range", "value"), + prevent_initial_call=True +) +def on_net_range(value): + """ + forwards input change of net_range slider to the display + @param value: the slider value of net_range + @return: the value for the display + """ + return value + + +@app.callback( + [Output("min_speed", "max"), Output("max_speed_display", "children")], + Input("max_speed", "value"), + prevent_initial_call=True +) +def on_max_speed(value): + """ + forwards input change of max_speed slider to the display and the max of min_speed + @param value: the slider value of max_speed + @return: a tuple consisting of the value for the display and the max value for min_speed + """ + return value, value + + +@app.callback( + [Output("max_speed", "min"), Output("min_speed_display", "children")], + Input("min_speed", "value"), + prevent_initial_call=True +) +def on_min_speed(value): + """ + forwards input change of min_speed slider to the display and the min of max_speed + @param value: the slider value of min_speed + @return: a tuple consisting of the value for the display and the min value for max_speed + """ + return value, value + + +@app.callback( + Output("sim_time_display", "children"), + Input("sim_time", "value"), + prevent_initial_call=True +) +def on_sim_time(value): + """ + forwards input change of sim_time slider to the display + @param value: the slider value of sim_time + @return: the value for the display + """ + return value + + +@app.callback( + [ + Output("plot", "figure", allow_duplicate=True), + Output("playIcon", "className", allow_duplicate=True), + Output("anim_slider", "value", allow_duplicate=True), + Output("anim_slider", "max", allow_duplicate=True), + Output("anim_slider", "marks"), + Output("save_button_content", "children"), + Output("anim_interval", "interval"), + ], + Input("saveButton", "n_clicks"), + [ + State("num_nodes", "value"), + State("net_range", "value"), + State("min_speed", "value"), + State("max_speed", "value"), + State("sim_time", "value"), + State("router", "value"), + State("msg_interval", "value"), + State("msg_size", "value"), + State("msg_ttl", "value"), + ], + prevent_initial_call=True +) +def on_save(n_clicks, + num_nodes, + net_range, + min_speed, + max_speed, + sim_time, + router, + msg_interval, + msg_size, + msg_ttl): + """ + saves changes to settings + @param n_clicks: n_clicks of the saveButton + @param num_nodes: state of num_nodes slider + @param net_range: state of net_range slider + @param min_speed: state of min_speed slider + @param max_speed: state of max_speed slider + @param sim_time: state of sim_time slider + @param router: state of router dropdown + @param msg_interval: state of msg_interval + @param msg_size: state of msg_size + @param msg_ttl: state of msg_ttl + @return: a tuple consisting of the plot figure, the play icon class, + the value of the animation slider, the max of the animation slider, + the content of the save button, the animation interval + """ + # pause the visualization + visualization.pause() + sim_time = int(sim_time) + # update the settings + settings["NUM_NODES"] = int(num_nodes) + settings["NET_RANGE"] = int(net_range) + settings["MIN_SPEED"] = float(min_speed) + settings["MAX_SPEED"] = float(max_speed) + settings["SIM_TIME"] = sim_time + settings["ROUTER"] = ROUTERS[router] + settings["ROUTER_NAME"] = router + settings["MESSAGES"]["MIN_INTERVAL"] = msg_interval[0] + settings["MESSAGES"]["MAX_INTERVAL"] = msg_interval[1] + settings["MESSAGES"]["MIN_SIZE"] = msg_size[0] + settings["MESSAGES"]["MAX_SIZE"] = msg_size[1] + settings["MESSAGES"]["MIN_TTL"] = msg_ttl[0] + settings["MESSAGES"]["MAX_TTL"] = msg_ttl[1] + # forward the settings to all channels + data_manager.update_settings(settings) + visualization.set_settings(settings) + # let the visualization know, that data has changed + visualization.on_data_update() + # start animation from start + visualization.set_frame(0) + return ( + # figure + visualization.get_figure(), + # play icon + "bi bi-play-fill", + # animation slider value + 0, + # animation slider max + sim_time, {i: f"{i}" for i in range(0, sim_time, int(sim_time / 20))}, + # save button content + "Save", + # animation interval + config.refresh_interval + ) + + +@app.callback( + [ + Output("num_nodes", "value", allow_duplicate=True), + Output("net_range", "value", allow_duplicate=True), + Output("min_speed", "value", allow_duplicate=True), + Output("max_speed", "value", allow_duplicate=True), + Output("sim_time", "value", allow_duplicate=True), + Output("router", "value", allow_duplicate=True), + Output("msg_interval", "value", allow_duplicate=True), + Output("msg_size", "value", allow_duplicate=True), + Output("msg_ttl", "value", allow_duplicate=True), + ], + Input("resetButton", "n_clicks"), + prevent_initial_call=True +) +def on_reset(n_clicks): + """ + resets the inputs + @param n_clicks: n_clicks of resetButton + @return: tuple consisting of num_nodes, net_range, min_speed, max_speed, + sim_time, router, msg_interval, msg_size, msg_ttl + """ + return ( + settings["NUM_NODES"], + settings["NET_RANGE"], + settings["MIN_SPEED"], + settings["MAX_SPEED"], + settings["SIM_TIME"], + settings["ROUTER_NAME"], + [settings["MESSAGES"]["MIN_INTERVAL"], settings["MESSAGES"]["MAX_INTERVAL"]], + [settings["MESSAGES"]["MIN_SIZE"], settings["MESSAGES"]["MAX_SIZE"]], + [settings["MESSAGES"]["MIN_TTL"], settings["MESSAGES"]["MAX_TTL"]], + ) + + +@app.callback( + Output("plot", "figure", allow_duplicate=True), + Input("checkbox_input", "value"), + prevent_initial_call=True +) +def on_checkbox_input(value): + """ + shows node ids based on checkbox value + @param value: the checkbox value + @return: the plot figure + """ + settings["SHOW_NODE_IDS"] = 1 in value + return visualization.get_figure() + + +# endregion + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0") diff --git a/ui/assets/styles.css b/ui/assets/styles.css new file mode 100644 index 0000000..f05b465 --- /dev/null +++ b/ui/assets/styles.css @@ -0,0 +1,109 @@ +.ml-0 { + margin-left: 0 !important; +} + +.ml-1 { + margin-left: 0.25rem !important; +} + +.ml-2 { + margin-left: 0.5rem !important; +} + +.ml-3 { + margin-left: 1rem !important; +} + +.ml-4 { + margin-left: 1.5rem !important; +} + +.ml-5 { + margin-left: 3rem !important; +} + +.mr-0 { + margin-right: 0 !important; +} + +.mr-1 { + margin-right: 0.25rem !important; +} + +.mr-2 { + margin-right: 0.5rem !important; +} + +.mr-3 { + margin-right: 1rem !important; +} + +.mr-4 { + margin-right: 1.5rem !important; +} + +.mr-5 { + margin-right: 3rem !important; +} + +.mx-auto { + margin-left: auto !important;; + margin-right: auto !important;; +} + +.scrollable { + overflow-y: scroll; +} + +.h-100 { + height: 95vh +} + +.h-40 { + height: 40%; +} + +html { + box-sizing: border-box; +} +*, *:before, *:after { + box-sizing: inherit; +} + +html, body {margin: 0; height: 100%; overflow: hidden} + + +.event-display { + height: 200px; + overflow: auto; + display: flex; + flex-direction: column-reverse; + border: solid #ced4da 1px; + padding: 10px; +} + +.control-button { + --bs-btn-padding-y: 0.175rem; + --bs-btn-padding-x: 0.5rem; +} + +.buffer { + height: 13px !important; +} + +.square { + position: relative; + width: 90%; +} + +.square:after { + content: ""; + display: block; + padding-bottom: 100%; +} + +.square-content { + position: absolute; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/ui/components/animation.py b/ui/components/animation.py new file mode 100644 index 0000000..25adc10 --- /dev/null +++ b/ui/components/animation.py @@ -0,0 +1,18 @@ +from dash import html + +from .animation_control import animation_control_component +from .animation_graph import animation_graph_component +from .animation_speed import animation_speed_component + + +def animation_component(fig, settings): + """ + builds all parts regarding animation (graphs, slider, speed, etc.) + @param fig: the plotly figure + @param settings: the settings defined in app.py + """ + return html.Div([ + animation_speed_component(), + animation_graph_component(fig, settings), + animation_control_component(settings) + ], className="mt-0 mb-2") diff --git a/ui/components/animation_control.py b/ui/components/animation_control.py new file mode 100644 index 0000000..b355f81 --- /dev/null +++ b/ui/components/animation_control.py @@ -0,0 +1,45 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + +from ..config import config + + +def animation_control_component(settings): + """ + builds the animation slider and step forward buttons + @param settings: the settings defined in app.py + """ + return dbc.Row([ + dbc.Col( + html.Div([ + dbc.Button( + html.I(id="playIcon", className="bi bi-play"), + id="playButton", + color="primary", + outline=True, + className="ml-2 control-button" + ), + dbc.Button( + html.I(id="stepIcon", className="bi bi-skip-end"), + id="stepButton", + color="primary", + outline=True, + className="ml-2 control-button" + ) + ]), + width=2 + ), + dbc.Col( + dcc.Slider( + id="anim_slider", + min=0, + max=settings["SIM_TIME"], + value=0, + drag_value=0, + step=10, + className="w-100", + marks={i: f'{i}' for i in + range(0, settings["SIM_TIME"], config.slider_marks_step)} + ), + width=10), + ]) diff --git a/ui/components/animation_graph.py b/ui/components/animation_graph.py new file mode 100644 index 0000000..d8cb80d --- /dev/null +++ b/ui/components/animation_graph.py @@ -0,0 +1,27 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + + +def animation_graph_component(fig, settings): + """ + builds the graphs for the animation. + @param fig: the plotly figure + @param settings: the settings defined in app.py + """ + return dbc.Row([ + dbc.Col( + html.Div([dcc.Graph(figure=fig, + id="plot", + config={"displayModeBar": False}, + className="mt-0 mb-0 square-content", + responsive=True)], className="square"), + width=9, + ), + dbc.Col( + html.Div([html.Div([html.Div([node], className="mt-2 mr-2"), + dbc.Progress(value=0, className="mt-3 buffer w-100")], + className="d-flex justify-content-between") for node in + range(settings["NUM_NODES"])], id="buffer_div"), + width=3 + ) + ]) diff --git a/ui/components/animation_speed.py b/ui/components/animation_speed.py new file mode 100644 index 0000000..ff72614 --- /dev/null +++ b/ui/components/animation_speed.py @@ -0,0 +1,25 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + + +def animation_speed_component(): + """ + builds the speed control for the animation + """ + return dbc.Row([ + dbc.Col( + html.Div(["Animation speed"], className="ml-2"), + width=2 + ), + dbc.Col( + dcc.Slider(id="speed_slider", + min=1, + max=50, + step=None, + marks={ + 1: "1", 2: "2", 5: "5", 10: "10", 20: "20", 30: "30", 40: "40", 50: "50" + }, + className="w-75"), + width=10 + ) + ], className="mt-2") diff --git a/ui/components/layout.py b/ui/components/layout.py new file mode 100644 index 0000000..e53f020 --- /dev/null +++ b/ui/components/layout.py @@ -0,0 +1,30 @@ +import dash_bootstrap_components as dbc +import dash_breakpoints +from dash import html, dcc + +from .animation import animation_component +from .settings import settings_component +from ..config import config + + +def layout_component(fig, settings): + """ + builds the apps layout + @param fig: the plotly figure + @param settings: the settings defined in app.py + """ + return html.Div([ + dbc.Row([ + dbc.Col( + animation_component(fig, settings), + width=8 + ), + dbc.Col( + settings_component(settings), + width=4 + ) + ], className="h-100"), + dcc.Interval(id="anim_interval", interval=config.refresh_interval), + html.Div("", id="size_helper", hidden=True), + dash_breakpoints.WindowBreakpoints(id="breakpoints"), + ], className="h-100 no-scroll") diff --git a/ui/components/settings.py b/ui/components/settings.py new file mode 100644 index 0000000..50f7672 --- /dev/null +++ b/ui/components/settings.py @@ -0,0 +1,29 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + +from .settings_inputs import settings_inputs_component + + +def settings_component(settings): + """ + builds the settings part of the app + @param settings: the settings defined in app.py + """ + return html.Div([ + settings_inputs_component(settings), + html.Div([ + dbc.Button("Reset", + id="resetButton", + color="secondary", + className="w-100", + style={"margin-right": "10px"}), + dbc.Button(dcc.Loading("Save", id="save_button_content", type="dot"), + id="saveButton", + color="primary", + className="w-100", + style={"margin-left": "10px"}), + ], className="d-flex justify-content-between mt-3"), # Buttons + html.Div([ + html.Div([], id="event_div", className="event-display") + ], className="mt-3") # Event Display + ], className="mt-5 h-100", style={"margin-right": "30px"}) diff --git a/ui/components/settings_checkbox.py b/ui/components/settings_checkbox.py new file mode 100644 index 0000000..e12b595 --- /dev/null +++ b/ui/components/settings_checkbox.py @@ -0,0 +1,30 @@ +import dash_bootstrap_components as dbc +from dash import html + + +def settings_checkbox_component(): + """ + builds the checkbox settings + """ + return html.Div([ + dbc.Button( + html.I(className="bi bi-caret-down-fill"), + id="checkbox_collapse_button", + className="mb-3 control-button", + color="primary", + n_clicks=0, + ), + dbc.Collapse( + dbc.Card(dbc.CardBody( + dbc.Checklist( + options=[ + {"label": "Show node ids", "value": 1}, + ], + value=[1], + id="checkbox_input", + ), + )), + id="checkbox_collapse", + is_open=False, + ), + ]) diff --git a/ui/components/settings_inputs.py b/ui/components/settings_inputs.py new file mode 100644 index 0000000..8a517ad --- /dev/null +++ b/ui/components/settings_inputs.py @@ -0,0 +1,19 @@ +from dash import html + +from .settings_checkbox import settings_checkbox_component +from .settings_messages import settings_messages_component +from .settings_sim import settings_sim_component + + +def settings_inputs_component(settings): + """ + builds the settings inputs + @param settings: the settings defined in app.py + """ + return html.Div( + [settings_checkbox_component()] + + settings_sim_component(settings) + + [html.Hr()] + + settings_messages_component(settings), + className="scrollable h-40" + ) diff --git a/ui/components/settings_messages.py b/ui/components/settings_messages.py new file mode 100644 index 0000000..e0bd683 --- /dev/null +++ b/ui/components/settings_messages.py @@ -0,0 +1,52 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + +import utils +from ..config import config + + +def settings_messages_component(settings): + """ + builds the message settings + @param settings: the settings defined in app.py + """ + return [ + html.H6("Messages"), + html.Div([ + dbc.Label('Interval (s)', html_for="msg_interval", className="form-label"), + dcc.RangeSlider(min=config.messages.min_interval, + max=config.messages.max_interval, + step=config.messages.interval_step, + marks=utils.get_marks_dict(config.messages.min_interval, + config.messages.max_interval, + config.messages.interval_step), + value=[settings["MESSAGES"]["MIN_INTERVAL"], + settings["MESSAGES"]["MAX_INTERVAL"]], + id="msg_interval") + ], className="mt-2"), # message interval + html.Div([ + dbc.Label('Size', html_for="msg_size", className="form-label"), + dcc.RangeSlider( + min=config.messages.min_size, + max=config.messages.max_size, + step=config.messages.size_step, + marks=utils.get_marks_dict(config.messages.min_size, + config.messages.max_size, + config.messages.size_step), + value=[settings["MESSAGES"]["MIN_SIZE"], settings["MESSAGES"]["MAX_SIZE"]], + id="msg_size") + ], className="mt-2"), # message size + html.Div([ + dbc.Label('Time to live (s)', html_for="msg_ttl", className="form-label"), + dcc.RangeSlider( + min=config.messages.min_ttl, + max=config.messages.max_ttl, + step=config.messages.ttl_step, + marks=utils.get_marks_dict(config.messages.min_ttl, + config.messages.max_ttl, + config.messages.ttl_step), + value=[settings["MESSAGES"]["MIN_TTL"], settings["MESSAGES"]["MAX_TTL"]], + id="msg_ttl" + ) + ], className="mt-2") # message ttl + ] diff --git a/ui/components/settings_sim.py b/ui/components/settings_sim.py new file mode 100644 index 0000000..c9c43e2 --- /dev/null +++ b/ui/components/settings_sim.py @@ -0,0 +1,82 @@ +import dash_bootstrap_components as dbc +from dash import html, dcc + +from ..config import config, ROUTERS + + +def settings_sim_component(settings): + """ + builds the simulation settings + @param settings: the settings defined in app.py + """ + return [ + html.Div([ + dbc.Label('Number of nodes', html_for="num_nodes", className="form-label"), + dbc.Row([ + dbc.Col(dcc.Input(id="num_nodes", + type="range", + min=config.min_num_of_nodes, + max=config.max_num_of_nodes, + step=config.num_of_nodes_step, + value=settings["NUM_NODES"], + className="form-control"), width=10), + dbc.Col(html.Span(settings["NUM_NODES"], id="num_nodes_display"), width=2) + ]) + ]), # number of nodes input + html.Div([ + dbc.Label('Net range', html_for="net_range", className="form-label"), + dbc.Row([ + dbc.Col(dcc.Input(id="net_range", + type="range", + min=config.min_net_range, + max=config.max_net_range, + step=config.net_range_step, + value=settings["NET_RANGE"], + className="form-control"), width=10), + dbc.Col(html.Span(settings["NET_RANGE"], id="net_range_display"), width=2) + ]) + ], className="mt-2"), # net range input + html.Div([ + dbc.Label('Min. speed', html_for="min_speed", className="form-label"), + dbc.Row([ + dbc.Col(dcc.Input(id="min_speed", + type="range", + min=config.min_speed, + max=settings["MAX_SPEED"], + step=config.speed_step, + value=settings["MIN_SPEED"], + className="form-control"), width=10), + dbc.Col(html.Span(settings["MIN_SPEED"], id="min_speed_display"), width=2) + ]) + ], className="mt-2"), # min speed input + html.Div([ + dbc.Label('Max. speed', html_for="max_speed", className="form-label"), + dbc.Row([ + dbc.Col(dcc.Input(id="max_speed", + type="range", + min=settings["MIN_SPEED"], + max=config.max_speed, + step=config.speed_step, + value=settings["MAX_SPEED"], + className="form-control"), width=10), + dbc.Col(html.Span(settings["MAX_SPEED"], id="max_speed_display"), width=2) + ]) + ], className="mt-2"), # min speed input + html.Div([ + dbc.Label('Simulation time', html_for="sim_time", className="form-label"), + dbc.Row([ + dbc.Col(dcc.Input(id="sim_time", + type="range", + min=config.min_sim_time, + max=config.max_sim_time, + step=config.sim_time_step, + value=settings["SIM_TIME"], + className="form-control"), width=10), + dbc.Col(html.Span(settings["SIM_TIME"], id="sim_time_display"), width=2) + ]) + ], className="mt-2"), # sim time input + html.Div([ + dbc.Label('Router', html_for="router", className="form-label"), + dbc.Select(options=list(ROUTERS.keys()), value="EpidemicRouter", id="router") + ], className="mt-2") # router input + ] diff --git a/ui/config.json b/ui/config.json new file mode 100644 index 0000000..36fdcfc --- /dev/null +++ b/ui/config.json @@ -0,0 +1,29 @@ +{ + "minSimTime": 0, + "maxSimTime": 3600, + "simTimeStep": 50, + "sliderMarksStep": 200, + "minNumOfNodes": 1, + "maxNumOfNodes": 10, + "numOfNodesStep": 1, + "minNetRange": 20, + "maxNetRange": 1000, + "netRangeStep": 5, + "minSpeed": 0, + "maxSpeed": 20, + "speedStep": 0.2, + "frameStepOptions": [1, 2, 5, 10, 100], + "refreshInterval": 80, + "capacity": 10000, + "messages": { + "minInterval": 0, + "maxInterval": 1000, + "intervalStep": 100, + "minSize": 0, + "maxSize": 8224, + "sizeStep": 1028, + "minTtl": 0, + "maxTtl": 3600, + "ttlStep": 400 + } +} \ No newline at end of file diff --git a/ui/config.py b/ui/config.py new file mode 100644 index 0000000..3e69b12 --- /dev/null +++ b/ui/config.py @@ -0,0 +1,59 @@ +import inspect +from dataclasses import dataclass +from typing import List + +from dataclasses_json import dataclass_json, LetterCase + +import pons + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class MessageConfig: + """config class for messages""" + min_interval: int + max_interval: int + interval_step: int + min_size: int + max_size: int + size_step: int + min_ttl: int + max_ttl: int + ttl_step: int + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Config: + """config class""" + min_sim_time: int + max_sim_time: int + sim_time_step: int + slider_marks_step: int + min_num_of_nodes: int + max_num_of_nodes: int + num_of_nodes_step: int + min_net_range: int + max_net_range: int + net_range_step: int + min_speed: float + max_speed: float + speed_step: float + frame_step_options: List[int] + refresh_interval: int + capacity: int + messages: MessageConfig + + +with open("config.json", "r") as file: + config = Config.from_json(file.read()) + +IGNORE_ROUTERS = ["Router"] + +ROUTERS = { + member: (cls(capacity=config.capacity) + if "capacity" in inspect.getfullargspec(cls.__init__).args + else cls()) + for (member, cls) in inspect.getmembers(pons.routing) if + inspect.isclass(cls) and member not in IGNORE_ROUTERS +} diff --git a/ui/data.py b/ui/data.py new file mode 100644 index 0000000..b05099c --- /dev/null +++ b/ui/data.py @@ -0,0 +1,281 @@ +import random +from typing import Dict, Any, Tuple, List + +import pandas as pd + +import pons + +RANDOM_SEED = 42 + +random.seed(RANDOM_SEED) + + +class DataManager: + """handling everything regarding the data preparation for the visualization""" + + def __init__(self, settings: Dict[str, Any]): + self._settings: Dict[str, Any] = settings + self._moves: List[Tuple[float, int, int, int]] = [] + self._helper: Dict[float, Dict[int, Tuple[int, int]]] = {} + self._data: Dict[float, Dict[str, List]] = {} + self._events: Dict[float, Dict[str, List]] = {} + self._events_list: List["pons.Event"] = [] + self._stores: Dict[float, Dict[int, List[pons.Message]]] = {} + self._connections: Dict[float, List[pd.DataFrame]] = {} + self._generate() + + def _generate_movement(self) -> List[Tuple[float, int, int, int]]: + world_size = self._settings["WORLD_SIZE"] + return pons.generate_randomwaypoint_movement(self._settings["SIM_TIME"], + self._settings["NUM_NODES"], + world_size[0], + world_size[1], + max_pause=60.0, + min_speed=self._settings["MIN_SPEED"], + max_speed=self._settings["MAX_SPEED"]) + + def _simulate(self): + """simulations as shown in sim.py""" + self._moves = self._generate_movement() + + num_nodes = self._settings["NUM_NODES"] + net = pons.NetworkSettings("WIFI_50m", range=self._settings["NET_RANGE"]) + + nodes = pons.generate_nodes( + num_nodes, net=[net], router=self._settings["ROUTER"]) + config = {"movement_logger": False, "peers_logger": False} + + msggenconfig = { + "type": "single", + "interval": ( + self._settings["MESSAGES"]["MIN_INTERVAL"], + self._settings["MESSAGES"]["MAX_INTERVAL"] + ), + "src": (0, num_nodes), + "dst": (0, num_nodes), + "size": ( + self._settings["MESSAGES"]["MIN_SIZE"], + self._settings["MESSAGES"]["MAX_SIZE"] + ), + "id": "M", + "ttl": ( + self._settings["MESSAGES"]["MIN_TTL"], + self._settings["MESSAGES"]["MAX_TTL"] + ) + } + + netsim = pons.NetSim(self._settings["SIM_TIME"], self._settings["WORLD_SIZE"], nodes, + self._moves, config=config, msggens=[msggenconfig]) + + netsim.setup() + netsim.run() + self._events_list = sorted(netsim.event_manager.events, key=lambda e: e.time) + + def _add(self, + data: Dict[float, Dict[str, List]], + helper: Dict[float, Dict[int, Tuple[int, int]]], + time: float, + node: int, + x: int, + y: int): + data[time]["node"].append(str(node)) + data[time]["x"].append(x) + data[time]["y"].append(y) + data[time]["stores"].append( + f"{node}
{'
'.join([msg.id for msg in self._stores[time][node]])}" + ) + helper[time][node] = (x, y) + + def _add_missing_data(self, + data: Dict[float, Dict[str, List]], + helper: Dict[float, Dict[int, Tuple[int, int]]] + ): + for time in range(self._settings["SIM_TIME"]): + time = float(time) + if time not in data: + data[time] = {"node": [], "x": [], "y": [], "stores": []} + helper[time] = {} + for node in range(self._settings["NUM_NODES"]): + if node in helper[time]: + continue + x, y = helper[time - 1][node] + self._add(data, helper, time, node, x, y) + + def _generate_data(self): + """generates movement data""" + data = {} + helper = {} + for move in self._moves: + time = move[0] + node = move[1] + x = move[2] + y = move[3] + if time not in data: + data[time] = {"node": [], "x": [], "y": [], "stores": []} + helper[time] = {} + self._add(data, helper, time, node, x, y) + self._add_missing_data(data, helper) + self._data = data + self._helper = helper + + def _add_event(self, target: Dict[float, Dict[str, List]], event: pons.Event): + """adds event to event data""" + time = event.time + if time not in self._helper or event.from_node not in self._helper[time]: + return + target[time]["type"].append(event.type) + from_x, from_y = self._helper[time][event.from_node] + target[time]["from_x"].append(from_x) + target[time]["from_y"].append(from_y) + to_x, to_y = self._helper[time][event.node] + target[time]["to_x"].append(to_x) + target[time]["to_y"].append(to_y) + + def _generate_event_data(self): + """generates event data""" + data = {} + for event in self._events_list: + if event.time not in data: + data[event.time] = {"type": [], "from_x": [], "from_y": [], "to_x": [], "to_y": []} + self._add_event(data, event) + self._events = data + + def _generate_connections(self): + """generates connection data""" + data = {} + current_conns = set() + # for each time + for time in range(0, self._settings["SIM_TIME"]): + data[time] = [] + # for every event that takes place at that time + for event in [event for event in self._events_list if event.time == time]: + # if event is CONNECTION_UP + if event.type == pons.EventType.CONNECTION_UP: + # add connection to current_conns + current_conns.add(frozenset((event.node, event.from_node))) + # else if event is CONNECTION_DOWN + elif event.type == pons.EventType.CONNECTION_DOWN: + # remove event from current_conns + conn = frozenset((event.node, event.from_node)) + if conn in current_conns: + current_conns.remove(conn) + # for each connection + for conn in current_conns: + node1, node2 = tuple(conn) + # extract coordinates and append to data + from_x, from_y = self._helper[time][node1] + to_x, to_y = self._helper[time][node2] + data[time].append(pd.DataFrame.from_dict({ + "x": [from_x, to_x], + "y": [from_y, to_y] + })) + self._connections = data + + def _generate_stores(self): + """generates the store data""" + # set stores for time=0 to empty sets + stores = {0: {i: set() for i in range(0, self._settings["NUM_NODES"])}} + # for each time starting with 1 + for time in range(1, self._settings["SIM_TIME"]): + # copy the stores of time - 1 + stores[time] = { + i: stores[time - 1][i].copy() for i in range(0, self._settings["NUM_NODES"]) + } + # for each event + for event in self._events_list: + # that takes place at the time + if time != event.time: + continue + # if event is of types CREATED, RECEIVED or DELIVERED + if event.type in [ + pons.EventType.CREATED, pons.EventType.RECEIVED, pons.EventType.DELIVERED + ]: + # add the message to the store + stores[time][event.node].add(event.message) + # else if event is of type DROPPED + elif event.type == pons.EventType.DROPPED: + if event.message in stores[time][event.node]: + # remove the message from the store + stores[time][event.node].remove(event.message) + self._stores = stores + + def _generate(self): + """generates all data""" + self._simulate() + self._generate_stores() + self._generate_data() + self._generate_event_data() + self._generate_connections() + + @staticmethod + def _to_dataframes(data: Dict[float, Dict[str, List]]) -> Dict[float, pd.DataFrame]: + """converts dicts to dataframes""" + dfs = {} + for time in data: + dfs[time] = pd.DataFrame.from_dict(data[time]) + return dfs + + def get_data(self) -> Dict[float, pd.DataFrame]: + """gets the movement data""" + return self._to_dataframes(self._data) + + def get_event_data(self, types) -> Dict[float, List[pd.DataFrame]]: + """ + loads event data for given types (used for message relay visualization) + @param types: list of event types + @return: dict mapping time to event dataframe + """ + data: Dict[float, Any] = {} + for time in self._events: + data[time] = [] + event = self._events[time] + for i in range(0, len(event["from_x"])): + if event["type"][i] in types: + data[time].append(pd.DataFrame.from_dict({ + "x": [event["from_x"][i], event["to_x"][i]], + "y": [event["from_y"][i], event["to_y"][i]] + })) + return data + + def get_buffer(self, time: float) -> Dict[int, float]: + """ + gets the data for the buffer animation + @param time: current time + @return: dict mapping node to buffer fullness + """ + capacity = self._settings["ROUTER"].capacity + data = {} + # for every node + for i in range(0, self._settings["NUM_NODES"]): + # get current stored messages + messages = self._stores[time][i] + # calculate total size + size = sum([msg.size for msg in messages]) + # calculate fullness + data[i] = (float(size) / float(capacity)) * 100. + return data + + def get_connection_data(self): + """gets the connection data between nodes""" + return self._connections + + def get_events(self, until: float, exclude_types=None): + """ + get events until given time with the possibility to exclude event types + @param until: time until the events should be returned + @param exclude_types: (optional) list of types to exclude + @return: events as strings in order + """ + if exclude_types is None: + exclude_types = [] + return reversed( + [str(e) for e in self._events_list if e.time <= until if e.type not in exclude_types] + ) + + def update_settings(self, settings: Dict[str, Any]): + """ + updates settings + @param settings: the settings dict from app.py + """ + self._settings = settings + self._generate() diff --git a/ui/requirements.txt b/ui/requirements.txt new file mode 100644 index 0000000..bc250e8 --- /dev/null +++ b/ui/requirements.txt @@ -0,0 +1,4 @@ +dash-bootstrap-components +dash +plotly +dash_breakpoints \ No newline at end of file diff --git a/ui/visualization.py b/ui/visualization.py new file mode 100644 index 0000000..616d8db --- /dev/null +++ b/ui/visualization.py @@ -0,0 +1,151 @@ +from enum import Enum +from typing import Dict, Any + +import plotly.graph_objects as go + +import pons +import ui + + +class VisualizationStatus(Enum): + """visualization status enum""" + PLAY = 0 + PAUSE = 1 + WAIT = 2 + + +class Visualization: + """class handling the visualization of the simulation""" + + def __init__(self, data_manager: "ui.DataManager", settings: Dict[str, Any]): + self._data_manager: ui.DataManager = data_manager + self.on_data_update() + self._settings: Dict[str, Any] = settings + self._frame: int = 0 + self._status: VisualizationStatus = VisualizationStatus.PAUSE + self.fig = None + + def get_figure(self) -> go.Figure: + """ + returns the figure for the current frame + """ + dataframe = self._data[float(self._frame)] + world_size = self._settings["WORLD_SIZE"] + + fig = go.Figure() + + # remove all margins + fig.update_layout(autosize=True, margin={ "l": 0, "r": 0, "t": 0, "b": 0}) + fig.update_yaxes(scaleanchor="x", scaleratio=1) + fig.update_xaxes(title="", range=[0, world_size[0]]) + # removes double 0 + fig.update_yaxes(title="", range=[0 + 61, world_size[1]]) + + # show node ids of SHOW_NODE_IDS is true + mode = "markers" + ("+text" if self._settings["SHOW_NODE_IDS"] else "") + + # core of nodes + fig.add_trace(go.Scattergl( + x=dataframe["x"], + y=dataframe["y"], + text=dataframe["node"], + textposition="top center", + marker={ + # seems to max out at like 30-40 for scattergl + "size": 7, + "color": "rgba(204, 102, 255, 255)", + }, + line={"width": 0}, + showlegend=False, + hoverinfo="text", + hovertext=dataframe["stores"], + mode=mode + )) + + # radius of ndoes + fig.add_trace(go.Scatter( + x=dataframe["x"], + y=dataframe["y"], + marker={ + "size": self._settings["NET_RANGE"] * self._settings["SIZEREF"], + "color": "rgba(0, 0, 0, 0)", + "line": { + "color": "rgba(102, 153, 255, 255)", + "width": 2 + } + }, + hoverinfo="skip", + showlegend=False + )) + + # connections + if self._frame in self._connected_events: + for df in self._connected_events[float(self._frame)]: + fig.add_trace(go.Scattergl( + x=df["x"], + y=df["y"], + showlegend=False, + line={"color": "rgba(0, 102, 204, 255)"}, + mode="lines" + )) + + # events + if self._frame in self._received_events: + for df in self._received_events[float(self._frame)]: + fig.add_trace(go.Scattergl( + x=df["x"], + y=df["y"], + showlegend=False, + line={"color": "rgba(78, 252, 3, 255)"}, + mode="lines" + )) + self.fig = fig + return fig + + def set_settings(self, settings: Dict): + """updates the settings""" + self._settings = settings + + def on_data_update(self): + """handles data update""" + self._data = self._data_manager.get_data() + self._connected_events = self._data_manager.get_connection_data() + self._received_events = self._data_manager.get_event_data([pons.EventType.RECEIVED]) + + def set_frame(self, frame: int): + """sets the frame""" + if frame > self._settings["SIM_TIME"]: + raise KeyError(f'{frame} is not in the simulation time {self._settings["SIM_TIME"]})') + self._frame = frame + + def update_frame(self): + """updates the frame using the frame step from settings""" + self._frame = (self._frame + self._settings["FRAME_STEP"]) % (self._settings["SIM_TIME"]) + + def get_frame(self) -> int: + """gets the current frame""" + return self._frame + + def is_playing(self) -> bool: + """returns true if the visualization is playing""" + return self._status == VisualizationStatus.PLAY + + def is_pausing(self) -> bool: + """returns true if the visualization is pausing""" + return self._status == VisualizationStatus.PAUSE + + def is_waiting(self) -> bool: + """returns true if the visualization is waiting""" + return self._status == VisualizationStatus.WAIT + + def play(self): + """sets the visualization status to PLAY""" + self._status = VisualizationStatus.PLAY + + def pause(self): + """sets the visualization status to PAUSE""" + self._status = VisualizationStatus.PAUSE + + def wait(self): + """sets the visualization status to WAIT""" + self._status = VisualizationStatus.WAIT diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..febe995 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,2 @@ +from utils.list_utils import to_lookup, contains +from utils.misc import get_marks_dict diff --git a/utils/list_utils.py b/utils/list_utils.py new file mode 100644 index 0000000..44a1cd0 --- /dev/null +++ b/utils/list_utils.py @@ -0,0 +1,23 @@ +from typing import List, Callable, Dict, TypeVar + +K = TypeVar("K") +V = TypeVar("V") + + +def to_lookup(values: List[V], key_selector: Callable[[V], K]) -> Dict[K, List[V]]: + """converts a list to a lookup table based on a key selector function""" + lookup = {} + for value in values: + key = key_selector(value) + if key not in lookup: + lookup[key] = [] + lookup[key].append(value) + return lookup + + +def contains(values: List[V], predicate: Callable[[V], bool]) -> bool: + """checks if a list contains a value conforming to a predicate""" + for value in values: + if predicate(value): + return True + return False diff --git a/utils/misc.py b/utils/misc.py new file mode 100644 index 0000000..7700753 --- /dev/null +++ b/utils/misc.py @@ -0,0 +1,2 @@ +def get_marks_dict(min: int, max: int, step: int): + return {step: str(step) for step in range(min, max + 1, step)} \ No newline at end of file