Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,8 +788,9 @@ One can refer to their respective solver's documentation to know which options e
The `show_online_optim` parameter can be set to `True` so the graphs nicely update during the optimization with the default values.
One can also directly declare `online_optim` as an `OnlineOptim` parameter to customize the behavior of the plotter.
Note that `show_online_optim` and `online_optim` are mutually exclusive.
Please also note that `OnlineOptim.MULTIPROCESS` is not available on Windows and only none of them are available on Macos.
To see how to run the server on Windows, please refer to the `getting_started/pendulum.py` example.
Please also note that `OnlineOptim.MULTIPROCESS` is not available on Windows or Macos.
On Macos, the default backend is `OnlineOptim.MULTIPROCESS_SERVER`, while `OnlineOptim.SERVER` remains available if one wants to start `resources/plotting_server.py` manually.
To see how to run the server explicitly, please refer to the `resources/plotting_server.py` example.
It is expected to slow down the optimization a bit.
`show_options` can be also passed as a dict to the plotter to customize the plotter's behavior.
If `online_optim` is set to `SERVER`, then a server must be started manually by instantiating an `PlottingServer` class (see `ressources/plotting_server.py`).
Expand Down Expand Up @@ -1720,7 +1721,7 @@ The type of online plotter to use.

The accepted values are:
NONE: No online plotter.
DEFAULT: Use the default online plotter depending on the OS (MULTIPROCESS on Linux, MULTIPROCESS_SERVER on Windows and NONE on MacOS).
DEFAULT: Use the default online plotter depending on the OS (MULTIPROCESS on Linux, MULTIPROCESS_SERVER on Windows and macOS).
MULTIPROCESS: The online plotter is in a separate process.
SERVER: The online plotter is in a separate server.
MULTIPROCESS_SERVER: The online plotter using the server automatically setup on a separate process.
Expand Down
12 changes: 9 additions & 3 deletions bioptim/examples/getting_started/basic_ocp.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ def main():
ocp.print(to_console=False, to_graph=False)

# --- Solve the ocp --- #
# Default is OnlineOptim.MULTIPROCESS on Linux, OnlineOptim.MULTIPROCESS_SERVER on Windows and None on MacOS
# To see the graphs on MacOS, one must run the server manually (see resources/plotting_server.py)
# Default is OnlineOptim.MULTIPROCESS on Linux and OnlineOptim.MULTIPROCESS_SERVER on Windows and MacOS
# On MacOS, OnlineOptim.SERVER remains available if you prefer to start resources/plotting_server.py manually
solver = Solver.IPOPT(online_optim=OnlineOptim.DEFAULT)

# # Show the constraints Jacobian sparsity
Expand All @@ -183,7 +183,13 @@ def main():
# --- Animate the solution --- #
viewer = "bioviz"
# viewer = "pyorerun"
sol.animate(n_frames=0, viewer=viewer, show_now=True)
try:
sol.animate(n_frames=0, viewer=viewer, show_now=True)
except RuntimeError as exc:
if viewer == "bioviz" and "bioviz must be install" in str(exc):
print("Animation skipped because bioviz is not installed.")
else:
raise

# # --- Saving the solver's output after the optimization --- #
# Here is an example of how we recommend to save the solution. Please note that sol.ocp is not picklable and that sol will be loaded using the current bioptim version, not the version at the time of the generation of the results.
Expand Down
11 changes: 11 additions & 0 deletions bioptim/gui/online_callback_multiprocess_server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from multiprocessing import Process
import socket

from .online_callback_server import PlottingServer, OnlineCallbackServer

Expand All @@ -15,6 +16,13 @@ def _start_server_internal(**kwargs):
PlottingServer(**kwargs)


def _find_available_tcp_port(host: str | None) -> int:
bind_host = host if host else "localhost"
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((bind_host, 0))
return sock.getsockname()[1]


class OnlineCallbackMultiprocessServer(OnlineCallbackServer):
def __init__(self, *args, **kwargs):
"""
Expand All @@ -26,6 +34,9 @@ def __init__(self, *args, **kwargs):
"""
host = kwargs["host"] if "host" in kwargs else None
port = kwargs["port"] if "port" in kwargs else None
if port is None:
port = _find_available_tcp_port(host)
kwargs["port"] = port
log_level = None
if "log_level" in kwargs:
log_level = kwargs["log_level"]
Expand Down
14 changes: 12 additions & 2 deletions bioptim/gui/online_callback_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,8 @@ def __init__(

self._host: Str = host if host else _DEFAULT_HOST
self._port: Int = port if port else _DEFAULT_PORT
self._socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket: socket.socket | None = None
self._reset_client_socket()

self._should_wait_ok_to_client_on_new_data: Bool = platform.system() == "Darwin"

Expand All @@ -502,6 +503,14 @@ def __init__(

self._initialize_connexion(**show_options)

def _reset_client_socket(self) -> None:
if self._socket is not None:
try:
self._socket.close()
except OSError:
pass
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

def _initialize_connexion(self, retries: Int = 0, **show_options) -> None:
"""
Initializes the connexion to the server
Expand All @@ -522,10 +531,11 @@ def _initialize_connexion(self, retries: Int = 0, **show_options) -> None:
if retries > 5:
raise RuntimeError(
"Could not connect to the plotter server, make sure it is running by calling 'PlottingServer()' on "
"another python instance or allowing for automatic start (Linux or Windows) of the server setting "
"another python instance or allowing for automatic start (Linux, Windows or macOS) of the server setting "
"the online_option to 'OnlineOptim.MULTIPROCESS_SERVER' when instantiating your solver."
)
else:
self._reset_client_socket()
time.sleep(1)
return self._initialize_connexion(retries + 1, **show_options)

Expand Down
9 changes: 7 additions & 2 deletions bioptim/gui/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -1185,8 +1185,13 @@ def _update_ydata(self, ydata: DMList | NpArrayList) -> None:
y_min = np.inf
for p in ax.get_children():
if isinstance(p, lines.Line2D):
y_min = min(y_min, np.nanmin(p.get_ydata()))
y_max = max(y_max, np.nanmax(p.get_ydata()))
y_data = np.asarray(p.get_ydata())
if y_data.size == 0 or np.isnan(y_data).all():
continue
y_min = min(y_min, np.nanmin(y_data))
y_max = max(y_max, np.nanmax(y_data))
if not np.isfinite(y_min) or not np.isfinite(y_max):
continue
ax.set_ylim(self._compute_ylim(y_min, y_max, 1.25))

for p in self.plots_vertical_lines:
Expand Down
10 changes: 9 additions & 1 deletion bioptim/interfaces/interface_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import platform
from time import perf_counter

from casadi import Importer, Function, horzcat, vertcat, sum1, sum2, nlpsol, SX, MX, DM, reshape, jacobian
Expand Down Expand Up @@ -37,7 +38,14 @@ def generic_online_optim(interface: SolverInterface, ocp, show_options: AnyDictO
online_optim = interface.opts.online_optim.get_default()
if online_optim is None:
return
elif online_optim == OnlineOptim.MULTIPROCESS:
if platform.system() == "Darwin" and online_optim == OnlineOptim.MULTIPROCESS:
raise NotImplementedError(
"online_optim MULTIPROCESS is not available on macOS. "
"Use OnlineOptim.MULTIPROCESS_SERVER for automatic plotting or OnlineOptim.SERVER with a manually started "
"PlottingServer."
)

if online_optim == OnlineOptim.MULTIPROCESS:
to_call = OnlineCallbackMultiprocess
elif online_optim == OnlineOptim.SERVER:
to_call = OnlineCallbackServer
Expand Down
4 changes: 2 additions & 2 deletions bioptim/misc/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class OnlineOptim(Enum):
Attributes
----------
NONE: No online plotting
DEFAULT: Default online plotting (MULTIPROCESS on Linux, MULTIPROCESS_SERVER on Windows and NONE on MacOS)
DEFAULT: Default online plotting (MULTIPROCESS on Linux and MULTIPROCESS_SERVER on Windows and macOS)
MULTIPROCESS: Multiprocess online plotting
SERVER: Server online plotting
MULTIPROCESS_SERVER: Multiprocess server online plotting
Expand All @@ -119,7 +119,7 @@ def get_default(self):

if platform.system() == "Linux":
return OnlineOptim.MULTIPROCESS
elif platform.system() == "Windows":
elif platform.system() in ("Windows", "Darwin"):
return OnlineOptim.MULTIPROCESS_SERVER
else:
return None
Expand Down
4 changes: 2 additions & 2 deletions resources/plotting_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
Since the server runs usings sockets, it is possible to run the server on a different machine than the one running the
optimization. This is useful when the optimization is run on a cluster and the plotting server is run on a local machine.

On Macos, this server is necessary as it won't connect using multiprocess. One can simply run the current script on
another terminal to access the online graphs
On Macos, this script is still useful if one wants to force OnlineOptim.SERVER explicitly or run the plotting server
on a different machine.
"""

from bioptim import PlottingServer
Expand Down
160 changes: 159 additions & 1 deletion tests/shard4/test_solver_options.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import pytest

from bioptim import Solver
from bioptim.misc.enums import SolverType
from bioptim.gui.online_callback_server import OnlineCallbackServer, _ResponseHeader
from bioptim.gui.online_callback_multiprocess_server import OnlineCallbackMultiprocessServer
from bioptim.interfaces.interface_utils import generic_online_optim
from bioptim.misc.enums import SolverType, OnlineOptim


class FakeSolver:
Expand All @@ -10,6 +15,12 @@ def __init__(
self.options_common = options_common


class FakeInterface:
def __init__(self, online_optim):
self.opts = type("Opts", (), {"online_optim": online_optim})()
self.options_common = {}


def test_ipopt_solver_options():
solver = Solver.IPOPT()
assert solver.type == SolverType.IPOPT
Expand Down Expand Up @@ -131,3 +142,150 @@ def test_ipopt_solver_options():

solver.set_nlp_scaling_method("gradient-fiesta")
assert solver.nlp_scaling_method == "gradient-fiesta"


def test_generic_online_optim_skips_when_default_is_unavailable(monkeypatch):
interface = FakeInterface(OnlineOptim.DEFAULT)

monkeypatch.setattr(OnlineOptim, "get_default", lambda self: None)

generic_online_optim(interface, ocp=None)

assert interface.options_common == {}


def test_generic_online_optim_uses_multiprocess_on_linux(monkeypatch):
interface = FakeInterface(OnlineOptim.DEFAULT)
callback = object()

monkeypatch.setattr("bioptim.misc.enums.platform.system", lambda: "Linux")
monkeypatch.setattr("bioptim.interfaces.interface_utils.OnlineCallbackMultiprocess", lambda *args, **kwargs: callback)

generic_online_optim(interface, ocp=None)

assert interface.options_common["iteration_callback"] is callback


def test_generic_online_optim_uses_multiprocess_server_on_windows(monkeypatch):
interface = FakeInterface(OnlineOptim.DEFAULT)
callback = object()

monkeypatch.setattr("bioptim.misc.enums.platform.system", lambda: "Windows")
monkeypatch.setattr(
"bioptim.interfaces.interface_utils.OnlineCallbackMultiprocessServer", lambda *args, **kwargs: callback
)

generic_online_optim(interface, ocp=None)

assert interface.options_common["iteration_callback"] is callback


def test_generic_online_optim_uses_multiprocess_server_on_macos(monkeypatch):
interface = FakeInterface(OnlineOptim.DEFAULT)
callback = object()

monkeypatch.setattr("bioptim.misc.enums.platform.system", lambda: "Darwin")
monkeypatch.setattr(
"bioptim.interfaces.interface_utils.OnlineCallbackMultiprocessServer", lambda *args, **kwargs: callback
)

generic_online_optim(interface, ocp=None)

assert interface.options_common["iteration_callback"] is callback


def test_generic_online_optim_rejects_multiprocess_on_macos(monkeypatch):
interface = FakeInterface(OnlineOptim.MULTIPROCESS)

monkeypatch.setattr("bioptim.interfaces.interface_utils.platform.system", lambda: "Darwin")

with pytest.raises(NotImplementedError, match="MULTIPROCESS is not available on macOS"):
generic_online_optim(interface, ocp=None)


def test_generic_online_optim_allows_server_on_macos(monkeypatch):
interface = FakeInterface(OnlineOptim.SERVER)
callback = object()

monkeypatch.setattr("bioptim.interfaces.interface_utils.platform.system", lambda: "Darwin")
monkeypatch.setattr("bioptim.interfaces.interface_utils.OnlineCallbackServer", lambda *args, **kwargs: callback)

generic_online_optim(interface, ocp=None)

assert interface.options_common["iteration_callback"] is callback


def test_online_callback_server_recreates_socket_between_retries(monkeypatch):
class FakeSocket:
def __init__(self, should_fail=False):
self.should_fail = should_fail
self.closed = False
self.sent = []

def connect(self, _):
if self.should_fail:
raise ConnectionRefusedError("server not ready")

def close(self):
self.closed = True

def sendall(self, data):
self.sent.append(data)

def recv(self, _):
return _ResponseHeader.PLOT_READY.encode()

sockets = [FakeSocket(should_fail=True), FakeSocket(should_fail=False)]

monkeypatch.setattr("bioptim.gui.online_callback_server.socket.socket", lambda *args, **kwargs: sockets.pop(0))
monkeypatch.setattr("bioptim.gui.online_callback_server.time.sleep", lambda *_: None)
monkeypatch.setattr(
"bioptim.gui.online_callback_server.OcpSerializable.from_ocp",
lambda ocp: type("FakeSerializable", (), {"serialize": lambda self: {}})(),
)
monkeypatch.setattr(
"bioptim.gui.online_callback_server.OptimizationVectorHelper.extract_step_times", lambda ocp, _: []
)
monkeypatch.setattr("bioptim.gui.online_callback_server.PlotOcp", lambda *args, **kwargs: "plotter")

callback = OnlineCallbackServer.__new__(OnlineCallbackServer)
callback.ocp = object()
callback._host = "localhost"
callback._port = 3050
callback._socket = None
callback._should_wait_ok_to_client_on_new_data = False
callback._has_received_ok = lambda: True

callback._reset_client_socket()
first_socket = callback._socket

callback._initialize_connexion()

assert first_socket.closed is True
assert callback._socket.sent
assert callback._plotter == "plotter"


def test_online_callback_multiprocess_server_uses_free_port_when_missing(monkeypatch):
recorded = {}

class FakeProcess:
def __init__(self, target, kwargs):
recorded["process_target"] = target
recorded["process_kwargs"] = kwargs

def start(self):
recorded["started"] = True

def fake_super_init(self, *args, **kwargs):
recorded["super_kwargs"] = kwargs

monkeypatch.setattr("bioptim.gui.online_callback_multiprocess_server._find_available_tcp_port", lambda host: 4242)
monkeypatch.setattr("bioptim.gui.online_callback_multiprocess_server.Process", FakeProcess)
monkeypatch.setattr(OnlineCallbackServer, "__init__", fake_super_init)

OnlineCallbackMultiprocessServer(ocp=None)

assert recorded["process_kwargs"]["port"] == 4242
assert recorded["super_kwargs"]["port"] == 4242
assert recorded["started"] is True
Loading