Operational MILP Warm Starts

The operational MILP now accepts heuristic schedules as warm starts via fhops solve-mip-operational --incumbent seed.csv. This page documents how to generate those CSVs, what the CLI/API derive from them, and—critically—why the feature is still experimental for medium/large ladders.

Workflow

  1. Generate a candidate schedule. Use any heuristic command that emits assignments with the canonical column schema:

    fhops solve-heur examples/med42/scenario.yaml \
      --iters 0 \
      --out tmp/med42_greedy_incumbent.csv
    

    The CSV must include machine_id, block_id, day, and shift_id. If you pass the optional assigned or production columns they are honoured when seeding Pyomo variables.

  2. Feed the incumbent to the MILP. Any solve-mip-operational invocation can reuse the schedule:

    fhops solve-mip-operational examples/med42/scenario.yaml \
      --solver gurobi \
      --solver-option Threads=36 \
      --solver-option TimeLimit=120 \
      --incumbent tmp/med42_greedy_incumbent.csv \
      --out tmp/med42_mip_seeded.csv
    

    The CLI rebuilds the fhops.optimization.operational_problem.OperationalProblem context, derives the implied machine positions and moves, activation binaries, per-role inventories, landing surplus, and leftovers, and then hands the seeded values to the solver as a MIP start (see Solver support).

    With the default open-source solver the same workflow is:

    fhops solve-mip-operational examples/tiny7/scenario.yaml \
      --time-limit 60 --out tmp/tiny7_mip.csv
    fhops solve-mip-operational examples/tiny7/scenario.yaml \
      --time-limit 5 --incumbent tmp/tiny7_mip.csv --out tmp/tiny7_mip_seeded.csv
    

    and the CLI reports how the start was used:

    Warm start: method=appsi_highs solver=appsi_highs seeded_slots=14 (accepted)
      MIP start solution is feasible, objective value is 279.796036
    
  3. Inspect the solver log. Successful warm starts show the candidate objective up-front. HiGHS prints MIP start solution is feasible, objective value is …; when it instead reports Attempting to find feasible solution by solving LP for user-supplied values of discrete variables followed by Model status : Infeasible the seed was rejected. With Gurobi, User MIP start did not produce a new incumbent solution means the solver ignored the seed (usually because it can find a better incumbent through its own heuristics). Add --debug to stream the full solver log.

Solver support

--solver

How the incumbent reaches the solver

Feedback

highs (default), appsi_highs

Pyomo’s default highs plugin (pyomo.contrib.solver) rejects the warmstart keyword, so seeded solves switch to the APPSI HiGHS interface (appsi_highs), which passes every seeded variable value to highspy.Highs.setSolution. Unseeded solves keep using highs.

The HiGHS log is captured; the CLI prints accepted/rejected plus the HiGHS MIP-start lines and telemetry records them under extra.warm_start.

gurobi, gurobi_direct, gurobi_persistent, cplex, cbc (≥ 2.8)

Unchanged: Pyomo’s solve(..., warmstart=True) (only when the plugin reports warm_start_capable()).

Read the solver log (--debug or e.g. --solver-option LogFile=run.log); accepted is reported as unknown.

anything else (e.g., glpk)

Solved without the incumbent; fhops.model.milp.driver.MilpWarmStartWarning is emitted.

Warm start not used in the CLI output.

From Python, fhops.model.milp.driver.solve_operational_milp() returns the same information in result["warm_start"] (method, solver, seeded_slots, accepted, acceptance, solver_messages). A solve stopped by its time limit still returns the best incumbent it holds—which, for an accepted warm start, is at least as good as the seed.

Landing capacity is a hard per-shift-slot constraint of the MILP when landing_surplus is weighted 0 (1.0.1, #125). Since #140 the heuristics’ repair also keeps every slot within a hard landing capacity, on single-shift days too, so their schedules are landing-feasible MILP starts: the med42 greedy incumbent above (--iters 0) is accepted by HiGHS (seeded_slots=198 (accepted), MIP start objective 13769.68; the heuristic objective of the same plan is 13392.56 because the heuristics also charge mobilisation for moves across idle slots). Before #140 the repair guarded landings on multi-shift days only, the med42 greedy incumbent overloaded single-shift landings and HiGHS rejected it (seeded_slots=212 (rejected)). Heuristic plans searched with a positive landing_surplus weight (e.g. the Tiny7/Small21 automatic overrides, see Objective Weights, Automatic Overrides, and Hard Violations) can still overload landings and are rejected by a MILP with a hard capacity. Seeding pays off: on the 3-shift BC case study ka_6 HiGHS finds no incumbent of its own within 30 minutes, while the SA plan (1500 iterations) is accepted and proven optimal in about a minute.

HiGHS does not always log a verdict about the MIP start (e.g. on large models stopped by a time limit). accepted then defaults to None unless acceptance can be inferred, in which case accepted=True and acceptance="inferred" ("log" when the verdict comes from the HiGHS log). The rule: a solution was returned and either (a) its assignment variables x equal the seeded ones, or (b) the seeded point satisfied every model constraint (FHOPS records the seeded values and checks them after the solve, only when needed; tolerance 1e-6), so HiGHS could adopt it as its first incumbent, and the returned objective is at least the seed objective. The CLI prints a note when acceptance was inferred.

Solve outcomes

The driver never raises because a model is infeasible, a time limit was reached without an incumbent, or the solver failed; it reports these cases (1.0.1, #115):

  • has_solution / objective: objective is None and assignments is an empty table with the usual columns when no feasible solution was loaded.

  • outcome: optimal, feasible (incumbent at a limit), infeasible, no_solution (limit without incumbent), or error.

  • solver_error: set for genuine solver failures rather than infeasibility, e.g. HiGHS refusing threads=1 after its global scheduler was initialised with another thread count in the same process (ERROR: Option 'threads' is set to 1 but global scheduler has already been initialized …), or any exception the solver interface raises — since 1.0.1 (#139) also gurobipy.GurobiError for a missing licence or a model too large for the size-limited licence that ships with pip install gurobipy (GurobiError: Model too large for size-limited license; …). The CLI prints it and exits with status 1.

  • warnings: locks the model dropped or pinned to idle instead of becoming infeasible, and solver fall-backs (below).

  • solver: the SolverFactory name that produced the result.

--solver auto (solver="auto") uses Gurobi when SolverFactory("gurobi") is available and HiGHS otherwise. When the Gurobi run fails (solver_error, e.g. the size-limited licence on small21), the model is solved again with HiGHS, a fhops.model.milp.driver.MilpSolverFallbackWarning is emitted and warnings starts with gurobi failed (…); falling back to highs (solver=auto).. The deprecated solve_mip / fhops solve-mip --driver auto follow the same rule. Solver-specific --solver-option values are forwarded to every solver tried.

Every solver path calls Pyomo with load_solutions=False and loads a solution only when the solver holds one (APPSI load_vars(), otherwise model.solutions.load_from(results)). Exported production values are clamped at 0 (no -0.0).

Current limitations

  • The plumbing works end-to-end: HiGHS accepts any incumbent that is feasible for the operational MILP (e.g. a previous MILP plan, or an SA plan searched under the scenario’s own objective weights — see above for why plans searched with the Tiny7/Small21 weight overrides are rejected), but med42 and large84 still reject greedy or short SA seeds. Those incumbents complete all blocks in ≈23–47 days, while the MILP needs high-quality assignments that respect every loader/landing constraint; the solver therefore finds its own incumbent faster than it can repair the provided schedule.

  • Gurobi and HiGHS require every binary implied by the incumbent (assignment, activation, head-start waiver, loader tail) to be populated, and the continuous position/move variables should match it. The CLI handles this automatically, but if you call fhops.model.milp.driver.solve_operational_milp() directly you must pass the OperationalProblem context so the helper can rebuild sequencing state.

  • Warm starts are best-effort. Providing an incumbent is always safe, yet you should not expect runtime improvements unless the seed is near-feasible for the operational MILP. Until we develop stronger heuristics (e.g., 60 s SA runs with repairs or rolling-horizon MILPs), treat --incumbent as a diagnostic tool rather than a guaranteed accelerator.

Practical guidance

  • Capture solver logs with --solver-option LogFile=med42.log (Gurobi) or --solver-option log_file=med42.log (HiGHS) when experimenting so you can confirm whether the incumbent was accepted.

  • Budget heuristics so they can produce a schedule that finishes close to the horizon (e.g., SA with --iters 2000 and --watch set to 60 seconds). Seeds that leave large staged volume or violate sequencing will be discarded.

  • Fall back to solver-based heuristics (pure Gurobi/HiGHS) if the warm start keeps getting rejected—the solver is often faster at generating its own incumbent once it hits the strong root relaxation.

Future work

Warm starts become truly useful once we can:

  • Generate med42-quality incumbents that satisfy loader/landing balance (potentially by repairing SA outputs with the SequencingTracker).

  • Lock in early-week decisions via rolling-horizon MILPs so the incumbent only needs to cover a subset of shifts at a time.

  • Expose benchmark automation that measures “seeded vs unseeded” runtime/gap curves in CI.

Until then, the published CLI/API docs intentionally describe the feature as operational-but-not-yet-practically-useful so users know what to expect.