diff --git a/Dockerfile b/Dockerfile index 3fc8aaf0..e34e85cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,14 @@ ENV PATH="$PATH:/usr/share/julia-1.5.3/bin" \ WORKDIR /app COPY . . -RUN julia -e 'using Pkg; Pkg.activate("."); Pkg.instantiate(); Pkg.add("Gurobi"); import Gurobi; using REISE' && \ +RUN julia -e 'using Pkg; \ + Pkg.activate("."); \ + Pkg.instantiate(); \ + Pkg.add("Gurobi"); \ + import Gurobi; \ + Pkg.add("GLPK"); \ + import GLPK; \ + using REISE' && \ pip install -r requirements.txt diff --git a/pyreisejl/utility/app.py b/pyreisejl/utility/app.py index b1d0feef..a962149d 100644 --- a/pyreisejl/utility/app.py +++ b/pyreisejl/utility/app.py @@ -12,7 +12,7 @@ Example request: curl -XPOST http://localhost:5000/launch/1234 -curl -XPOST http://localhost:5000/launch/1234?threads=42 +curl -XPOST http://localhost:5000/launch/1234?threads=4&solver=glpk curl http://localhost:5000/status/1234 """ @@ -30,10 +30,14 @@ def get_script_path(): def launch_simulation(scenario_id): cmd_call = ["python3", "-u", get_script_path(), str(scenario_id), "--extract-data"] threads = request.args.get("threads", None) + solver = request.args.get("solver", None) if threads is not None: cmd_call.extend(["--threads", str(threads)]) + if solver is not None: + cmd_call.extend(["--solver", solver]) + proc = Popen(cmd_call, stdout=PIPE, stderr=PIPE, start_new_session=True) entry = SimulationState(scenario_id, proc) state.add(entry) diff --git a/pyreisejl/utility/call.py b/pyreisejl/utility/call.py index c9178add..42f28306 100644 --- a/pyreisejl/utility/call.py +++ b/pyreisejl/utility/call.py @@ -1,21 +1,14 @@ import os -from time import time - -import pandas as pd from pyreisejl.utility import const, parser from pyreisejl.utility.extract_data import extract_scenario from pyreisejl.utility.helpers import ( - InvalidDateArgument, - InvalidInterval, WrongNumberOfArguments, - extract_date_limits, get_scenario, insert_in_file, sec2hms, - validate_time_format, - validate_time_range, ) +from pyreisejl.utility.launchers import get_launcher def _record_scenario(scenario_id, runtime): @@ -34,111 +27,6 @@ def _record_scenario(scenario_id, runtime): ) -class Launcher: - """Parent class for solver-specific scenario launchers. - - :param str start_date: start date of simulation as 'YYYY-MM-DD HH:MM:SS', - where HH, MM, and SS are optional. - :param str end_date: end date of simulation as 'YYYY-MM-DD HH:MM:SS', - where HH, MM, and SS are optional. - :param int interval: length of each interval in hours - :param str input_dir: directory with input data - :raises InvalidDateArgument: if start_date is posterior to end_date - :raises InvalidInterval: if the interval doesn't evently divide the given date range - """ - - def __init__(self, start_date, end_date, interval, input_dir): - """Constructor.""" - # extract time limits from 'demand.csv' - with open(os.path.join(input_dir, "demand.csv")) as profile: - min_ts, max_ts, freq = extract_date_limits(profile) - - dates = pd.date_range(start=min_ts, end=max_ts, freq=freq) - - start_ts = validate_time_format(start_date) - end_ts = validate_time_format(end_date, end_date=True) - - # make sure the dates are within the time frame we have data for - validate_time_range(start_ts, min_ts, max_ts) - validate_time_range(end_ts, min_ts, max_ts) - - if start_ts > end_ts: - raise InvalidDateArgument( - f"The start date ({start_ts}) cannot be after the end date ({end_ts})." - ) - - # Julia starts at 1 - start_index = dates.get_loc(start_ts) + 1 - end_index = dates.get_loc(end_ts) + 1 - - # Calculate number of intervals - ts_range = end_index - start_index + 1 - if ts_range % interval > 0: - raise InvalidInterval( - "This interval does not evenly divide the given date range." - ) - self.start_index = start_index - self.interval = interval - self.n_interval = int(ts_range / interval) - self.input_dir = input_dir - print("Validation complete!") - - def _print_settings(self): - print("Launching scenario with parameters:") - print( - { - "interval": self.interval, - "n_interval": self.n_interval, - "start_index": self.start_index, - "input_dir": self.input_dir, - "execute_dir": self.execute_dir, - "threads": self.threads, - } - ) - - def launch_scenario(self): - # This should be defined in sub-classes - raise NotImplementedError - - -class GurobiLauncher(Launcher): - def launch_scenario(self, execute_dir=None, threads=None, solver_kwargs=None): - """Launches the scenario. - - :param None/str execute_dir: directory for execute data. None defaults to an - execute folder that will be created in the input directory - :param None/int threads: number of threads to use. - :param None/dict solver_kwargs: keyword arguments to pass to solver (if any). - :return: (*int*) runtime of scenario in seconds - """ - self.execute_dir = execute_dir - self.threads = threads - self._print_settings() - # Import these within function because there is a lengthy compilation step - from julia.api import Julia - - Julia(compiled_modules=False) - from julia import Gurobi # noqa: F401 - from julia import REISE - - start = time() - REISE.run_scenario_gurobi( - interval=self.interval, - n_interval=self.n_interval, - start_index=self.start_index, - inputfolder=self.input_dir, - outputfolder=self.execute_dir, - threads=self.threads, - ) - end = time() - - runtime = round(end - start) - hours, minutes, seconds = sec2hms(runtime) - print(f"Run time: {hours}:{minutes:02d}:{seconds:02d}") - - return runtime - - def main(args): # Get scenario info if using PowerSimData if args.scenario_id: @@ -162,7 +50,7 @@ def main(args): ) raise WrongNumberOfArguments(err_str) - launcher = GurobiLauncher( + launcher = get_launcher(args.solver)( args.start_date, args.end_date, args.interval, diff --git a/pyreisejl/utility/launchers.py b/pyreisejl/utility/launchers.py new file mode 100644 index 00000000..addde78f --- /dev/null +++ b/pyreisejl/utility/launchers.py @@ -0,0 +1,172 @@ +import os +from time import time + +import pandas as pd + +from pyreisejl.utility.helpers import ( + InvalidDateArgument, + InvalidInterval, + extract_date_limits, + sec2hms, + validate_time_format, + validate_time_range, +) + + +class Launcher: + """Parent class for solver-specific scenario launchers. + + :param str start_date: start date of simulation as 'YYYY-MM-DD HH:MM:SS', + where HH, MM, and SS are optional. + :param str end_date: end date of simulation as 'YYYY-MM-DD HH:MM:SS', + where HH, MM, and SS are optional. + :param int interval: length of each interval in hours + :param str input_dir: directory with input data + :raises InvalidDateArgument: if start_date is posterior to end_date + :raises InvalidInterval: if the interval doesn't evently divide the given date range + """ + + def __init__(self, start_date, end_date, interval, input_dir): + """Constructor.""" + # extract time limits from 'demand.csv' + with open(os.path.join(input_dir, "demand.csv")) as profile: + min_ts, max_ts, freq = extract_date_limits(profile) + + dates = pd.date_range(start=min_ts, end=max_ts, freq=freq) + + start_ts = validate_time_format(start_date) + end_ts = validate_time_format(end_date, end_date=True) + + # make sure the dates are within the time frame we have data for + validate_time_range(start_ts, min_ts, max_ts) + validate_time_range(end_ts, min_ts, max_ts) + + if start_ts > end_ts: + raise InvalidDateArgument( + f"The start date ({start_ts}) cannot be after the end date ({end_ts})." + ) + + # Julia starts at 1 + start_index = dates.get_loc(start_ts) + 1 + end_index = dates.get_loc(end_ts) + 1 + + # Calculate number of intervals + ts_range = end_index - start_index + 1 + if ts_range % interval > 0: + raise InvalidInterval( + "This interval does not evenly divide the given date range." + ) + self.start_index = start_index + self.interval = interval + self.n_interval = int(ts_range / interval) + self.input_dir = input_dir + print("Validation complete!") + + def _print_settings(self): + print("Launching scenario with parameters:") + print( + { + "interval": self.interval, + "n_interval": self.n_interval, + "start_index": self.start_index, + "input_dir": self.input_dir, + "execute_dir": self.execute_dir, + "threads": self.threads, + } + ) + + def launch_scenario(self): + # This should be defined in sub-classes + raise NotImplementedError + + +class GLPKLauncher(Launcher): + def launch_scenario(self, execute_dir=None, threads=None, solver_kwargs=None): + """Launches the scenario. + + :param None/str execute_dir: directory for execute data. None defaults to an + execute folder that will be created in the input directory + :param None/int threads: number of threads to use. + :param None/dict solver_kwargs: keyword arguments to pass to solver (if any). + :return: (*int*) runtime of scenario in seconds + """ + self.execute_dir = execute_dir + self.threads = threads + self._print_settings() + print("INFO: threads not supported by GLPK, ignoring") + + from julia.api import Julia + + Julia(compiled_modules=False) + from julia import GLPK # noqa: F401 + from julia import REISE + + start = time() + REISE.run_scenario( + interval=self.interval, + n_interval=self.n_interval, + start_index=self.start_index, + inputfolder=self.input_dir, + outputfolder=self.execute_dir, + optimizer_factory=GLPK.Optimizer, + ) + end = time() + + runtime = round(end - start) + hours, minutes, seconds = sec2hms(runtime) + print(f"Run time: {hours}:{minutes:02d}:{seconds:02d}") + + return runtime + + +class GurobiLauncher(Launcher): + def launch_scenario(self, execute_dir=None, threads=None, solver_kwargs=None): + """Launches the scenario. + + :param None/str execute_dir: directory for execute data. None defaults to an + execute folder that will be created in the input directory + :param None/int threads: number of threads to use. + :param None/dict solver_kwargs: keyword arguments to pass to solver (if any). + :return: (*int*) runtime of scenario in seconds + """ + self.execute_dir = execute_dir + self.threads = threads + self._print_settings() + # Import these within function because there is a lengthy compilation step + from julia.api import Julia + + Julia(compiled_modules=False) + from julia import Gurobi # noqa: F401 + from julia import REISE + + start = time() + REISE.run_scenario_gurobi( + interval=self.interval, + n_interval=self.n_interval, + start_index=self.start_index, + inputfolder=self.input_dir, + outputfolder=self.execute_dir, + threads=self.threads, + ) + end = time() + + runtime = round(end - start) + hours, minutes, seconds = sec2hms(runtime) + print(f"Run time: {hours}:{minutes:02d}:{seconds:02d}") + + return runtime + + +_launch_map = {"gurobi": GurobiLauncher, "glpk": GLPKLauncher} + + +def get_available_solvers(): + return list(_launch_map.keys()) + + +def get_launcher(solver): + if solver is None: + return GurobiLauncher + if solver.lower() not in _launch_map.keys(): + raise ValueError("Invalid solver") + return _launch_map[solver] diff --git a/pyreisejl/utility/parser.py b/pyreisejl/utility/parser.py index fe973cc3..07bad6de 100644 --- a/pyreisejl/utility/parser.py +++ b/pyreisejl/utility/parser.py @@ -1,5 +1,7 @@ import argparse +from pyreisejl.utility.launchers import get_available_solvers + def parse_call_args(): parser = argparse.ArgumentParser(description="Run REISE.jl simulation.") @@ -74,6 +76,13 @@ def parse_call_args(): "This flag is only used if the extract-data flag is set.", ) + solvers = ",".join(get_available_solvers()) + parser.add_argument( + "--solver", + help="Specify the solver to run the optimization. Will default to gurobi. " + f"Current solvers available are {solvers}.", + ) + # For backwards compatability with PowerSimData parser.add_argument( "scenario_id",