diff --git a/README.md b/README.md index ff7ca33..72e7e54 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ CLI for running Hierarchical Engine for Large-scale Infrastructure Co-Simulations (HELICS). -![](https://user-images.githubusercontent.com/1813121/118163708-8bd4f380-b3df-11eb-8e18-99e9de81836f.png) - - Supports configurations of federates using plugins - Allows running of federation using a runner configuration @@ -48,9 +46,16 @@ Usage: helics run [OPTIONS] Run HELICS federation Options: - --path PATH + --path PATH Path to config.json that describes how to + run a federation [required] --silent - --help Show this message and exit. + --no-log-files + --no-kill-on-error Do not kill all federates on error + -l, --broker-loglevel, --loglevel TEXT + Log level for HELICS broker + --profile Profile flag + -w, --web Run the web interface on startup + --help Show this message and exit. ``` ```bash @@ -65,6 +70,17 @@ Options: --help Show this message and exit. ``` +```bash +$ helics profile-plot --help + +Usage: helics profile-plot [OPTIONS] + +Options: + --path PATH Path to profile.txt that describes profiling results of a + federation [required] + --help Show this message and exit. +``` + ### Usage ```bash @@ -86,6 +102,11 @@ Hello from Federate 2 ``` +```bash +helics run --path examples/pi-exchange/runner.json --profile +helics profile-plot examples/pi-exchange/profile.txt +``` + ### Installation ``` diff --git a/examples/pi-exchange/pireceiver.py b/examples/pi-exchange/pireceiver.py index 3f20f26..e1c27dc 100644 --- a/examples/pi-exchange/pireceiver.py +++ b/examples/pi-exchange/pireceiver.py @@ -16,6 +16,7 @@ h.helicsFederateInfoSetTimeProperty(fedinfo, h.helics_property_time_delta, 0.01) vfed = h.helicsCreateValueFederate(federate_name, fedinfo) + print(f"{federate_name}: Value federate created") sub = h.helicsFederateRegisterSubscription(vfed, f"globaltopic{sys.argv[1]}", "") @@ -28,9 +29,9 @@ currenttime = -1 -while currenttime <= 100: +while currenttime <= 10: - currenttime = h.helicsFederateRequestTime(vfed, 100) + currenttime = h.helicsFederateRequestTime(vfed, 10) value = h.helicsInputGetString(sub) print(f"{federate_name}: Received value = {value} at time {currenttime}") diff --git a/examples/pi-exchange/pisender.py b/examples/pi-exchange/pisender.py index 2ade505..b707a15 100644 --- a/examples/pi-exchange/pisender.py +++ b/examples/pi-exchange/pisender.py @@ -4,6 +4,7 @@ from math import pi import helics as h +import random federate_name = f"SenderFederate{sys.argv[1]}" @@ -17,14 +18,10 @@ h.helicsFederateInfoSetTimeProperty(fedinfo, h.helics_property_time_delta, 0.01) vfed = h.helicsCreateValueFederate(federate_name, fedinfo) + print(f"{federate_name}: Value federate created") -pub = h.helicsFederateRegisterGlobalTypePublication( - vfed, f"globaltopic{sys.argv[1]}", "double", "" -) -pub = h.helicsFederateRegisterTypePublication( - vfed, f"localtopic{sys.argv[1]}", "double", "" -) +pub = h.helicsFederateRegisterGlobalTypePublication(vfed, f"globaltopic{sys.argv[1]}", "double", "") print(f"{federate_name}: Publication registered") @@ -34,7 +31,7 @@ this_time = 0.0 value = pi -for t in range(5, 10): +for t in range(0, 10): val = value currenttime = h.helicsFederateRequestTime(vfed, t) @@ -42,7 +39,8 @@ h.helicsPublicationPublishDouble(pub, val) print(f"{federate_name}: Sending value pi = {val} at time {currenttime}") - time.sleep(1) + # Computing user needs + time.sleep(float(sys.argv[1]) * (1 + (0.5 - random.random()) / 10)) h.helicsFederateFinalize(vfed) print(f"{federate_name}: Federate finalized") diff --git a/examples/pi-exchange/runner.json b/examples/pi-exchange/runner.json index f62cffe..0de992a 100644 --- a/examples/pi-exchange/runner.json +++ b/examples/pi-exchange/runner.json @@ -19,6 +19,48 @@ "host": "localhost", "name": "pisender3" }, + { + "directory": ".", + "exec": "python -u pisender.py 4", + "host": "localhost", + "name": "pisender4" + }, + { + "directory": ".", + "exec": "python -u pisender.py 5", + "host": "localhost", + "name": "pisender5" + }, + { + "directory": ".", + "exec": "python -u pisender.py 6", + "host": "localhost", + "name": "pisender6" + }, + { + "directory": ".", + "exec": "python -u pisender.py 7", + "host": "localhost", + "name": "pisender7" + }, + { + "directory": ".", + "exec": "python -u pisender.py 8", + "host": "localhost", + "name": "pisender8" + }, + { + "directory": ".", + "exec": "python -u pisender.py 9", + "host": "localhost", + "name": "pisender9" + }, + { + "directory": ".", + "exec": "python -u pisender.py 10", + "host": "localhost", + "name": "pisender10" + }, { "directory": ".", "exec": "python -u pireceiver.py 1", @@ -36,6 +78,48 @@ "exec": "python -u pireceiver.py 3", "host": "localhost", "name": "pireceiver3" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 4", + "host": "localhost", + "name": "pireceiver4" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 5", + "host": "localhost", + "name": "pireceiver5" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 6", + "host": "localhost", + "name": "pireceiver6" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 7", + "host": "localhost", + "name": "pireceiver7" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 8", + "host": "localhost", + "name": "pireceiver8" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 9", + "host": "localhost", + "name": "pireceiver9" + }, + { + "directory": ".", + "exec": "python -u pireceiver.py 10", + "host": "localhost", + "name": "pireceiver10" } ], "name": "pi-exchange" diff --git a/helics_cli/cli.py b/helics_cli/cli.py index 1b8f11c..a7a7da5 100644 --- a/helics_cli/cli.py +++ b/helics_cli/cli.py @@ -22,6 +22,7 @@ from .status_checker import CheckStatusThread from .utils import extra from .utils.extra import echo +from . import profile as p logger = logging.getLogger(__name__) @@ -46,7 +47,10 @@ def cli(ctx, verbose): @cli.command() @click.option( - "--name", type=click.STRING, default="HELICSFederation", help="Name of the folder that contains the config.json", + "--name", + type=click.STRING, + default="HELICSFederation", + help="Name of the folder that contains the config.json", ) @click.option("--path", type=click.Path(), default="./", help="Path to the folder") @click.option("--purge/--no-purge", default=False, help="Delete folder if it exists") @@ -67,7 +71,8 @@ def setup(name, path, purge): os.makedirs(path) else: echo( - "The following path already exists: {path}".format(path=path), status="error", + "The following path already exists: {path}".format(path=path), + status="error", ) echo("Please remove the directory and try again.", status="error") return None @@ -88,16 +93,41 @@ def setup(name, path, purge): @cli.command() @click.option( - "--path", required=True, type=click.Path(file_okay=True, exists=True), help="Path to config.json that describes how to run a federation", + "--path", + required=True, + type=click.Path(file_okay=True, exists=True), + help="Path to profile.txt that describes profiling results of a federation", +) +def profile_plot(path): + p.plot(p.profile(path), kind="realtime") + + +@cli.command() +@click.option( + "--path", + required=True, + type=click.Path(file_okay=True, exists=True), + help="Path to config.json that describes how to run a federation", ) @click.option("--silent", is_flag=True) @click.option("--no-log-files", is_flag=True, default=False) @click.option("--no-kill-on-error", is_flag=True, default=False, help="Do not kill all federates on error") @click.option( - "--broker-loglevel", "--loglevel", "-l", type=str, default="error", help="Log level for HELICS broker", + "--broker-loglevel", + "--loglevel", + "-l", + type=str, + default="error", + help="Log level for HELICS broker", +) +@click.option( + "--profile", + is_flag=True, + default=False, + help="Profile flag", ) @click.option("--web", "-w", is_flag=True, default=False, help="Run the web interface on startup") -def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): +def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error, profile): """ Run HELICS federation """ @@ -108,7 +138,8 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): if not os.path.exists(path_to_config): echo( - "Unable to find file `config.json` in path: {path_to_config}".format(path_to_config=path_to_config), status="error", + "Unable to find file `config.json` in path: {path_to_config}".format(path_to_config=path_to_config), + status="error", ) return None @@ -124,18 +155,40 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): process_handler.message_handler.set_enable(True) if web: - process_handler.run_web(target=startup, args=(False, path_to_config, process_handler.message_handler,), daemon=True) + process_handler.run_web( + target=startup, + args=( + False, + path_to_config, + process_handler.message_handler, + ), + daemon=True, + ) if "broker" in config.keys() and config["broker"] is not False: if config["broker"] is not True and "observer" in config["broker"].keys(): process_handler.run_broker( - target=observer.run, args=(len(config["federates"]), path_to_config, broker_loglevel, process_handler.message_handler,), daemon=True + target=observer.run, + args=( + len(config["federates"]), + path_to_config, + broker_loglevel, + process_handler.message_handler, + ), + daemon=True, ) process_handler.broker_process.name = "broker" else: + cmd = "helics_broker -f {num_fed} --loglevel={log_level}" + if profile: + profiler_txt = os.path.join(os.path.abspath(os.path.expanduser(path)), "profile.txt") + if os.path.exists(profiler_txt): + os.remove(profiler_txt) + cmd += " --profiler=profile.txt" broker_o = open(os.path.join(path, "broker.log"), "w") + cmd = cmd.format(num_fed=len(config["federates"]), log_level=broker_loglevel) broker_subprocess = subprocess.Popen( - shlex.split("helics_broker -f {num_fed} --loglevel={log_level}".format(num_fed=len(config["federates"]), log_level=broker_loglevel)), + shlex.split(cmd), cwd=os.path.abspath(os.path.expanduser(path)), stdout=broker_o, stderr=broker_o, @@ -155,7 +208,8 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): for f in config["federates"]: if not silent: echo( - "Running federate {name} as a background process".format(name=f["name"]), status="info", + "Running federate {name} as a background process".format(name=f["name"]), + status="info", ) if log is True: @@ -169,7 +223,13 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): if "env" in f: for k, v in f["env"].items(): env[k] = v - p = subprocess.Popen(shlex.split(f["exec"]), cwd=os.path.abspath(os.path.expanduser(directory)), stdout=o, stderr=o, env=env,) + p = subprocess.Popen( + shlex.split(f["exec"]), + cwd=os.path.abspath(os.path.expanduser(directory)), + stdout=o, + stderr=o, + env=env, + ) p.name = f["name"] except FileNotFoundError as e: @@ -183,7 +243,8 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): try: t.start() echo( - "Waiting for {} processes to finish ...".format(len(process_handler.process_list)), status="info", + "Waiting for {} processes to finish ...".format(len(process_handler.process_list)), + status="info", ) for p in process_handler.process_list: p.wait() @@ -210,16 +271,22 @@ def run(path, silent, no_log_files, broker_loglevel, web, no_kill_on_error): for p in process_handler.process_list: if p.returncode != 0 and p.returncode is not None: echo( - "Process {} exited with return code {}".format(p.name, p.returncode), status="error", + "Process {} exited with return code {}".format(p.name, p.returncode), + status="error", ) echo( - "Done.", status="info", + "Done.", + status="info", ) @cli.command() @click.option( - "--path", required=True, type=click.Path(exists=True), default="./", help="Path to config.json file that describes how to run a federation", + "--path", + required=True, + type=click.Path(exists=True), + default="./", + help="Path to config.json file that describes how to run a federation", ) def validate(path): """ @@ -248,7 +315,10 @@ def validate(path): @cli.command() @click.option( - "--n-federates", required=True, type=click.INT, help="Number of federates to observe", + "--n-federates", + required=True, + type=click.INT, + help="Number of federates to observe", ) @click.option("--path", type=click.Path(exists=True), default="./", help="Internal path to config file used for filtering output") @click.option("--broker_loglevel", "--loglevel", "-l", type=click.INT, default=2, help="Log level for HELICS broker") @@ -258,7 +328,10 @@ def observe(n_federates: int, path: str, log_level) -> int: @cli.command() @click.option( - "--browser", is_flag=True, default=False, help="Open browser on startup", + "--browser", + is_flag=True, + default=False, + help="Open browser on startup", ) @click.option("--path", type=click.Path(exists=True), default="./", help="Path for database file") def server(browser: bool, path: str): diff --git a/helics_cli/profile.py b/helics_cli/profile.py new file mode 100644 index 0000000..5c3aac9 --- /dev/null +++ b/helics_cli/profile.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +import os +import re + +import matplotlib +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import numpy as np + +CURRENT_DIR = os.path.realpath(os.path.basename(__file__)) + +# SenderFederate2[131074](initializing)HELICS CODE ENTRY<4570827706580384>[t=-1000000] +PATTERN = re.compile( + r""" + (?P\w+) # SenderFederate2 + \[(\d+)\] # [131074] + \((?P\w+)\) # (initializing) + (?P(?:\w|\s)+) # HELICS CODE ENTRY + \<(?P\d+(?:\|\d+)?)\> # <4570827706580384|534534523453> + \[t=(?P-?\d*\.{0,1}\d+)\] # [t=-1000000] + """, + re.X, +) + + +def profile(filename): + with open(filename) as f: + data = f.read() + data = data.replace(r"", "").replace(r"", "") + names = [] + states = [] + messages = [] + simtimes = [] + realtimes = [] + time_marker = {} + for line in data.splitlines(): + m = PATTERN.match(line) + name = m.group("name") + state = m.group("state") + message = m.group("message") + simtime = float(m.group("simtime")) + try: + realtime = float(m.group("realtime")) + except: + realtime, markertime = m.group("realtime").split("|") + time_marker[name] = float(markertime) + realtime = float(realtime) + names.append(name) + states.append(state) + messages.append(message) + simtimes.append(simtime) + realtimes.append(realtime) + + profile = {} + for name in set(names): + profile[name] = [] + + for (name, state, message, simtime, realtime) in zip(names, states, messages, simtimes, realtimes): + if state == "created": + continue + if "ENTRY" in message: + profile[name].append({"s_enter": simtime, "r_enter": realtime}) + if "EXIT" in message: + profile[name][-1]["s_end"] = simtime + profile[name][-1]["r_end"] = realtime + + return profile + + +def plot(profile, kind="simulation", **kwargs): + profiles = [] + names = {k: i for i, k in enumerate(sorted(profile.keys()))} + + if kind == "simulation": + end = "s_end" + enter = "s_enter" + scaling = 1 + elif kind == "realtime": + end = "r_end" + enter = "r_enter" + scaling = 1e9 + else: + raise Exception("unknown kind") + + for k in profile.keys(): + for i in profile[k]: + if end in i.keys() and enter in i.keys(): + i["name"] = k + i[enter] = i[enter] / scaling + i[end] = i[end] / scaling + profiles.append(i) + + fig, axs = plt.subplots(1, 1, figsize=(16, 9)) + ax = axs + + cmap = matplotlib.cm.get_cmap('seismic') + + values = list(d[end] - d[enter] for d in profiles) + norm = matplotlib.colors.Normalize(vmin=min(values), vmax=max(values)) + + m_enter = min(d[enter] for d in profiles) + for i, d in enumerate(profiles): + d["color"] = cmap(norm(values[i])) + + for d in profiles: + d[enter] = d[enter] - m_enter + d[end] = d[end] - m_enter + ax.barh(names[d["name"]], d[end] - d[enter], left=d[enter], edgecolor = d["color"], color = d["color"], **kwargs) + + if kind == "Simulation": + ax.set_xlabel("Simulation Time (s)") + else: + ax.set_xlabel("Real Time (s)") + ax.set_yticks([i for i in range(0, len(names))]) + ax.set_yticklabels(sorted(names.keys())) + ax.set_facecolor('#f0f0f0') + fig.colorbar(matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax, location = "top") + plt.show() + + +if __name__ == "__main__": + plot(profile(os.path.abspath(os.path.join(CURRENT_DIR, "../examples/pi-exchange/profile.txt"))), kind = "realtime") diff --git a/setup.py b/setup.py index adad83a..faa209a 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ author="Dheepak Krishnamurthy", license="BSD-compatible", packages=["web", "database"] + find_packages(), - install_requires=["future", "six", "click", "jinja2", "helics>=2.7.0", "flask"], + install_requires=["future", "six", "click", "jinja2", "helics>=2.7.0", "flask", "matplotlib", "numpy"], keywords=["helics", "cosimulation"], include_package_data=True, entry_points={"console_scripts": ["helics = helics_cli.cli:cli"]},