Source code for graphcalc.solvers

# src/graphcalc/solvers.py
from __future__ import annotations

import inspect
import os
import sys
from pathlib import Path
from shutil import which
from typing import Any, Callable, Dict, List, Optional, Type, Union

import pulp
from pulp import GLPK_CMD, HiGHS_CMD, PULP_CBC_CMD

__all__ = [
    "SolverSpec",
    "resolve_solver",
    "get_default_solver",
    "solve_or_raise",
    "with_solver",
    "doctor",
]

# --------------------------------------------------------------------------------------
# Public type alias: accept strings, dict specs, callable factories, classes, or objects
# --------------------------------------------------------------------------------------
SolverSpec = Optional[
    Union[
        str,
        Dict[str, Any],
        pulp.apis.core.LpSolver,
        pulp.apis.core.LpSolver_CMD,
        Callable[[], Union[pulp.apis.core.LpSolver, pulp.apis.core.LpSolver_CMD]],
        Type[pulp.apis.core.LpSolver],
        Type[pulp.apis.core.LpSolver_CMD],
    ]
]

# Friendly user aliases -> canonical PuLP registry keys
_PULP_ALIASES: Dict[str, Optional[str]] = {
    # delegate
    "auto": None,
    "default": None,

    # HiGHS
    "highs": "HiGHS_CMD",
    "highs_cmd": "HiGHS_CMD",
    "highs_cmd.exe": "HiGHS_CMD",
    "highs.exe": "HiGHS_CMD",
    "highs.cmd": "HiGHS_CMD",

    # CBC
    "cbc": "PULP_CBC_CMD",
    "cbc.exe": "PULP_CBC_CMD",
    "coin_cmd": "COIN_CMD",

    # GLPK
    "glpk": "GLPK_CMD",
    "glpsol": "GLPK_CMD",
    "glpsol.exe": "GLPK_CMD",
}

# --------------------------------------------------------------------------------------
# Internal helpers
# --------------------------------------------------------------------------------------
def _ensure_pulp_solver(obj: Any) -> pulp.apis.core.LpSolver:
    """Return obj if it's a PuLP solver instance; otherwise raise TypeError."""
    if isinstance(obj, (pulp.apis.core.LpSolver, pulp.apis.core.LpSolver_CMD)):
        return obj
    raise TypeError(
        f"Expected a PuLP solver instance, got {type(obj).__name__}. "
        "Pass a pulp solver object or a spec I can resolve."
    )


def _exec_names_for(key: str) -> List[str]:
    """Executable basenames we may look for on PATH / Conda."""
    k = key.lower()
    if k in {"highs", "highs_cmd"}:
        return ["highs", "highs.exe"]
    if k == "cbc":
        return ["cbc", "cbc.exe"]
    if k in {"glpk", "glpsol"}:
        return ["glpsol", "glpsol.exe"]
    return []


def _candidate_paths(exec_names: List[str]) -> List[str]:
    """Possible executable paths, including PATH and common Conda locations (Windows)."""
    out: List[str] = []

    # 1) PATH
    for name in exec_names:
        p = which(name)
        if p:
            out.append(p)

    # 2) Conda-style prefixes (Windows puts binaries under <env>\Library\bin\)
    prefixes: List[Path] = []
    for env_var in ("CONDA_PREFIX", "MAMBA_ROOT_PREFIX"):
        v = os.getenv(env_var)
        if v:
            prefixes.append(Path(v))
    # Also try sys.prefix (useful inside venv/conda)
    prefixes.append(Path(sys.prefix))

    for prefix in prefixes:
        libbin = prefix / "Library" / "bin"
        if libbin.exists():
            for name in exec_names:
                p = libbin / name
                if p.exists():
                    out.append(str(p))

    # 3) Common Unix locations (mostly redundant with PATH, but harmless)
    for name in exec_names:
        for d in ("/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"):
            p = Path(d) / name
            if p.exists():
                out.append(str(p))

    # Deduplicate, preserve order
    seen = set()
    uniq = []
    for p in out:
        if p not in seen:
            seen.add(p)
            uniq.append(p)
    return uniq


def _build_cmd_solver_from_path(solver_key: str, exec_path: Optional[str], *, msg: bool) -> pulp.apis.core.LpSolver_CMD:
    """Instantiate a command-line solver with a specific executable path."""
    k = solver_key.lower()
    if k in {"highs", "highs_cmd"}:
        return HiGHS_CMD(path=exec_path, msg=msg)
    if k == "cbc":
        return PULP_CBC_CMD(path=exec_path, msg=msg)
    if k in {"glpk", "glpsol"}:
        return GLPK_CMD(path=exec_path, msg=msg)
    raise ValueError(f"Unsupported command solver key for path binding: {solver_key!r}")


def _is_available(solver: pulp.apis.core.LpSolver) -> bool:
    """Return True if the solver is usable (CMD backends must find their binary)."""
    avail = getattr(solver, "available", None)
    if callable(avail):
        try:
            return bool(avail())
        except Exception:
            return False
    # Non-CMD solvers (Python APIs) may not expose .available(); assume usable.
    return True


# --------------------------------------------------------------------------------------
# Default solver discovery (Windows/Conda aware + env overrides)
# --------------------------------------------------------------------------------------
[docs] def get_default_solver(msg: bool = False) -> pulp.apis.core.LpSolver_CMD: r""" Return the first available LP solver backend, honoring environment overrides. Order of preference: 1) HiGHS 2) CBC 3) GLPK Environment overrides --------------------- - ``GRAPHCALC_SOLVER``: Friendly name (``"highs"``, ``"cbc"``, ``"glpsol"``) or PuLP key (``"HiGHS_CMD"``, ``"PULP_CBC_CMD"``, ``"GLPK_CMD"``), or ``"auto"``. - ``GRAPHCALC_SOLVER_PATH``: Full path to an executable (e.g., ``C:\miniconda3\envs\gc\Library\bin\highs.exe``). Returns ------- pulp.apis.core.LpSolver_CMD Configured with ``msg`` according to the argument. """ def _is_available(solver: pulp.apis.core.LpSolver) -> bool: """True if the solver is usable (CMD backends must find their binary).""" avail = getattr(solver, "available", None) if callable(avail): try: return bool(avail()) except Exception: return False # Non-CMD solvers may not expose .available(); assume usable. return True # 1) Read overrides forced = os.getenv("GRAPHCALC_SOLVER", "").strip() forced_key = forced or "auto" # Map friendly aliases to PuLP keys when necessary reg_name = _PULP_ALIASES.get(forced_key.lower(), forced_key) forced_path = os.getenv("GRAPHCALC_SOLVER_PATH", "").strip() or None # 2) If a path is forced, try to bind it to the chosen (or default) solver if forced_path: # If no specific solver is forced, try HiGHS -> CBC -> GLPK against this path trial_order = (["highs", "cbc", "glpsol"] if reg_name is None else [forced_key]) for key in trial_order: try: return _build_cmd_solver_from_path(key, forced_path, msg=msg) except Exception: continue # fall through # 3) If solver name is forced (no path), try registry first (covers GUROBI/CPLEX etc.) if reg_name not in (None, ""): solver_obj = None try: solver_obj = pulp.getSolver(reg_name, msg=msg) if _is_available(solver_obj): return _ensure_pulp_solver(solver_obj) # else: try executable discovery for this forced key except Exception: pass exec_names = _exec_names_for(forced_key) for p in _candidate_paths(exec_names): try: return _build_cmd_solver_from_path(forced_key, p, msg=msg) except Exception: pass # Could not honor forced solver; fall back to auto below. # 4) Auto-discovery in preferred order for key in ("highs", "cbc", "glpsol"): # Try registry first in case the user has a Python API solver installed try: reg = _PULP_ALIASES.get(key, key) solver_obj = pulp.getSolver(reg, msg=msg) if _is_available(solver_obj): return _ensure_pulp_solver(solver_obj) # else: try known executable locations for this key except Exception: pass for p in _candidate_paths(_exec_names_for(key)): try: return _build_cmd_solver_from_path(key, p, msg=msg) except Exception: continue # 5) Nothing found: assemble platform-specific guidance if sys.platform.startswith("win"): hint = ( "\nWindows tips:\n" "- If you use Conda: conda install -c conda-forge highs coincbc glpk\n" " (Executables typically land under <env>\\Library\\bin\\.)\n" "- Or set:\n" " set GRAPHCALC_SOLVER=highs\n" " set GRAPHCALC_SOLVER_PATH=C:\\path\\to\\highs.exe\n" ) else: hint = ( "\nmacOS/Linux tips:\n" "- Homebrew: brew install highs glpk cbc\n" "- Ubuntu: sudo apt-get install coinor-cbc glpk-utils (use conda for HiGHS)\n" "- Conda: conda install -c conda-forge highs coincbc glpk\n" ) raise EnvironmentError( "No supported LP solver was found.\n" "Tried HiGHS, CBC, and GLPK via PuLP registry and common executable locations." + hint )
# -------------------------------------------------------------------------------------- # Flexible solver resolution (string/dict/class/callable/instance) # --------------------------------------------------------------------------------------
[docs] def resolve_solver( solver: SolverSpec, *, msg: bool = False, solver_options: Optional[Dict[str, Any]] = None, ) -> pulp.apis.core.LpSolver: """ Resolve a flexible solver spec into a PuLP solver instance. Accepts ------- - None Use :func:`get_default_solver`. - str PuLP registry name (e.g., "HiGHS_CMD", "GUROBI_CMD") or friendly alias ("auto", "highs", "cbc", "glpsol"). - dict {"name": <str>, "options": {...}} forwarded to :func:`pulp.getSolver`. - class Subclass of PuLP solver; will be instantiated with ``solver_options``. - callable Zero-argument factory returning a PuLP solver instance. - instance An already-constructed PuLP solver object. Notes ----- - ``msg`` is propagated when we construct the solver (string/dict/class). If the caller provides an instance, we do not override its settings. """ opts = dict(solver_options or {}) opts.setdefault("msg", msg) # None -> default if solver is None: s = get_default_solver(msg=msg) if hasattr(s, "msg"): s.msg = msg return s # Already-constructed if isinstance(solver, (pulp.apis.core.LpSolver, pulp.apis.core.LpSolver_CMD)): return solver # Callable factory if callable(solver) and not inspect.isclass(solver): return _ensure_pulp_solver(solver()) # Dict spec if isinstance(solver, dict): name = solver.get("name") if not isinstance(name, str): raise ValueError("Dict solver spec must include a string 'name'.") local_opts = dict(solver.get("options") or {}) local_opts.setdefault("msg", msg) s = pulp.getSolver(_PULP_ALIASES.get(name.lower(), name), **local_opts) return _ensure_pulp_solver(s) # Class if inspect.isclass(solver): if not issubclass(solver, (pulp.apis.core.LpSolver, pulp.apis.core.LpSolver_CMD)): raise TypeError("Solver class must subclass a PuLP solver base.") return solver(**opts) # String if isinstance(solver, str): key = solver.strip() reg_name = _PULP_ALIASES.get(key.lower(), key) if reg_name is None: # "auto"/"default" return resolve_solver(None, msg=msg, solver_options=opts) s = pulp.getSolver(reg_name, **opts) return _ensure_pulp_solver(s) raise TypeError( "Unrecognized solver specification. " "Use None, a string, a dict {'name','options'}, a callable returning a PuLP solver, " "a PuLP solver class, or a PuLP solver instance." )
# -------------------------------------------------------------------------------------- # Uniform solve & error handling # --------------------------------------------------------------------------------------
[docs] def solve_or_raise(prob: pulp.LpProblem, solver: pulp.apis.core.LpSolver) -> None: """Solve a PuLP problem and raise ValueError if the status is not Optimal.""" prob.solve(solver) if pulp.LpStatus[prob.status] != "Optimal": raise ValueError(f"No optimal solution found (status: {pulp.LpStatus[prob.status]}).")
# -------------------------------------------------------------------------------------- # Decorator to inject uniform solver kwargs + solve() helper into invariants # -------------------------------------------------------------------------------------- from functools import wraps
[docs] def with_solver(fn): """ Decorator that adds a uniform `(verbose=False, *, solver=None, solver_options=None)` interface to solver-backed functions without repeating boilerplate. The wrapped function must accept parameters `(verbose: bool = False, solve=None, **kwargs)`. The decorator injects a `solve(prob)` closure that resolves the solver with `resolve_solver` and calls `solve_or_raise`. """ @wraps(fn) def wrapper(*args, verbose: bool = False, solver: SolverSpec = None, solver_options: Optional[Dict[str, Any]] = None, **kwargs): def _solve(prob: pulp.LpProblem): s = resolve_solver(solver, msg=verbose, solver_options=solver_options) solve_or_raise(prob, s) return fn(*args, verbose=verbose, solve=_solve, **kwargs) return wrapper
# -------------------------------------------------------------------------------------- # Diagnostics for users ("graphcalc doctor") # --------------------------------------------------------------------------------------
[docs] def doctor() -> str: r""" Return a multi-line diagnostic string describing which solver GraphCalc would use right now, including how it was found. Examples -------- >>> out = doctor() >>> isinstance(out, str) True >>> out.startswith("GraphCalc Solver Doctor") True >>> "Selected" in out and "Path trial(s)" in out True """ lines: List[str] = [] lines.append("GraphCalc Solver Doctor") lines.append("-----------------------") forced = os.getenv("GRAPHCALC_SOLVER", "").strip() or "(none)" forced_path = os.getenv("GRAPHCALC_SOLVER_PATH", "").strip() or "(none)" lines.append(f"Preferred (env) : GRAPHCALC_SOLVER={forced}") lines.append(f"Forced path : GRAPHCALC_SOLVER_PATH={forced_path}") # What would resolve_solver(None) pick? try: s = get_default_solver(msg=False) cls = s.__class__.__name__ # try to pull path attr for CMD solvers path_attr = getattr(s, "path", None) path_disp = str(path_attr) if path_attr else "(registry)" lines.append(f"Selected : {cls} [{path_disp}]") except Exception as e: lines.append(f"Selected : <none> ({type(e).__name__}: {e})") # Show PATH trials trials = [] for key in ("highs", "cbc", "glpsol"): for p in _candidate_paths(_exec_names_for(key)): trials.append(f"{key}: {p}") if trials: lines.append("Path trial(s) :") for t in trials: lines.append(f" - {t}") else: lines.append("Path trial(s) : (no candidates found)") return "\n".join(lines)