|
| 1 | +""" |
| 2 | +Solver backend abstraction. |
| 3 | +
|
| 4 | +Two backends: |
| 5 | + - "juliac" (default): calls a precompiled shared library via ctypes. |
| 6 | + No Julia installation required. |
| 7 | + - "juliacall": calls MOI + GenOpt + HiGHS through juliacall. |
| 8 | + Requires Julia (installed lazily by juliacall on first use). |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from abc import ABC, abstractmethod |
| 14 | +from typing import TYPE_CHECKING |
| 15 | + |
| 16 | +if TYPE_CHECKING: |
| 17 | + from jumpy.model import Model |
| 18 | + |
| 19 | + |
| 20 | +class Backend(ABC): |
| 21 | + """Abstract solver backend.""" |
| 22 | + |
| 23 | + @abstractmethod |
| 24 | + def optimize(self, model: Model) -> list[float]: |
| 25 | + """Solve the model and return the solution vector.""" |
| 26 | + ... |
| 27 | + |
| 28 | + |
| 29 | +class JuliacBackend(Backend): |
| 30 | + """ |
| 31 | + Default backend: calls a precompiled Julia shared library via ctypes. |
| 32 | +
|
| 33 | + The library is built with juliac from: |
| 34 | + MOI + GenOpt + Bridges + HiGHS |
| 35 | +
|
| 36 | + No Julia installation required. |
| 37 | + """ |
| 38 | + |
| 39 | + def __init__(self): |
| 40 | + self._lib = None |
| 41 | + |
| 42 | + def _load_lib(self): |
| 43 | + if self._lib is not None: |
| 44 | + return |
| 45 | + import ctypes |
| 46 | + import importlib.resources |
| 47 | + # TODO: resolve platform-specific library path |
| 48 | + # For now, search standard locations |
| 49 | + import os |
| 50 | + lib_names = [ |
| 51 | + "libjumpy_backend.so", |
| 52 | + "libjumpy_backend.dylib", |
| 53 | + "jumpy_backend.dll", |
| 54 | + ] |
| 55 | + for name in lib_names: |
| 56 | + for search_dir in [os.path.dirname(__file__), os.getcwd(), "/usr/local/lib"]: |
| 57 | + path = os.path.join(search_dir, name) |
| 58 | + if os.path.exists(path): |
| 59 | + self._lib = ctypes.CDLL(path) |
| 60 | + return |
| 61 | + raise FileNotFoundError( |
| 62 | + "Could not find the compiled JuMPy backend library.\n" |
| 63 | + "The juliac-compiled shared library (libjumpy_backend.so) is not installed.\n" |
| 64 | + "Either:\n" |
| 65 | + " 1. Install the pre-built wheel: pip install jumpy\n" |
| 66 | + " 2. Use the juliacall backend: jp.Model(backend='juliacall')\n" |
| 67 | + ) |
| 68 | + |
| 69 | + def optimize(self, model: Model) -> list[float]: |
| 70 | + self._load_lib() |
| 71 | + data = model._serialize() |
| 72 | + # TODO: implement ctypes calls to the compiled library |
| 73 | + raise NotImplementedError( |
| 74 | + "juliac backend not yet compiled. " |
| 75 | + "Use jp.Model(backend='juliacall') for now." |
| 76 | + ) |
| 77 | + |
| 78 | + |
| 79 | +class JuliaCallBackend(Backend): |
| 80 | + """ |
| 81 | + Optional backend: calls Julia directly through juliacall. |
| 82 | +
|
| 83 | + Requires `pip install jumpy[juliacall]`. Julia is installed lazily |
| 84 | + by juliacall on first use if not already present. |
| 85 | +
|
| 86 | + This backend has full flexibility — it can use any solver or MOI |
| 87 | + feature, not just what's compiled into the juliac library. |
| 88 | + """ |
| 89 | + |
| 90 | + def __init__(self): |
| 91 | + self._jl = None |
| 92 | + |
| 93 | + def _init_julia(self): |
| 94 | + if self._jl is not None: |
| 95 | + return |
| 96 | + try: |
| 97 | + from juliacall import Main as jl |
| 98 | + except ImportError: |
| 99 | + raise ImportError( |
| 100 | + "juliacall is not installed.\n" |
| 101 | + "Install it with: pip install jumpy[juliacall]\n" |
| 102 | + "This will also install Julia automatically if needed." |
| 103 | + ) from None |
| 104 | + # Install and load Julia packages on first use |
| 105 | + jl.seval("using Pkg") |
| 106 | + for pkg in ["MathOptInterface", "HiGHS", "GenOpt"]: |
| 107 | + jl.seval(f""" |
| 108 | + if !haskey(Pkg.project().dependencies, "{pkg}") |
| 109 | + Pkg.add("{pkg}") |
| 110 | + end |
| 111 | + """) |
| 112 | + jl.seval("import MathOptInterface as MOI") |
| 113 | + jl.seval("import GenOpt") |
| 114 | + jl.seval("import HiGHS") |
| 115 | + # TODO: load GenOpt once it's registered / available |
| 116 | + self._jl = jl |
| 117 | + |
| 118 | + def optimize(self, model: Model) -> list[float]: |
| 119 | + self._init_julia() |
| 120 | + jl = self._jl |
| 121 | + return self._build_and_solve(jl, model) |
| 122 | + |
| 123 | + def _build_and_solve(self, jl, model: Model) -> list[float]: |
| 124 | + from jumpy.bridge_juliacall import build_moi_model |
| 125 | + return build_moi_model(jl, model) |
| 126 | + |
| 127 | + |
| 128 | +_BACKENDS = { |
| 129 | + "juliac": JuliacBackend, |
| 130 | + "juliacall": JuliaCallBackend, |
| 131 | +} |
| 132 | + |
| 133 | + |
| 134 | +def get_backend(name: str) -> Backend: |
| 135 | + cls = _BACKENDS.get(name) |
| 136 | + if cls is None: |
| 137 | + raise ValueError( |
| 138 | + f"Unknown backend '{name}'. Choose from: {list(_BACKENDS.keys())}" |
| 139 | + ) |
| 140 | + return cls() |
0 commit comments