fhops.optimization Package

The optimisation stack converts fhops.scenario.contract.Problem objects into either Pyomo MIP models or heuristic schedules. Use these modules when:

  • Running simulated annealing / ILS / Tabu heuristics from fhops.optimization.heuristics.

  • Building the operational problem context (fhops.optimization.operational_problem.build_operational_problem()) consumed by the operational MILP (fhops.model.milp.driver.solve_operational_milp()) and the heuristics.

The exact model is the operational MILP (fhops.model.milp, fhops solve-mip-operational), the formulation documented in the FHOPS paper:

from fhops.scenario.io import load_scenario
from fhops.scenario.contract import Problem
from fhops.optimization.operational_problem import build_operational_problem
from fhops.model.milp.driver import solve_operational_milp

pb = Problem.from_scenario(load_scenario("examples/tiny7/scenario.yaml"))
ctx = build_operational_problem(pb)
result = solve_operational_milp(ctx.bundle, time_limit=300, context=ctx)
if result["has_solution"]:
    print(result["outcome"], result["objective"], len(result["assignments"]))
else:
    # "infeasible", "no_solution" (time limit) or "error" (see result["solver_error"])
    print(result["outcome"], result["solver_error"])

Deprecated legacy MIP (1.0.1, #127)

fhops.optimization.mip.solve_mip() and fhops.optimization.mip.builder.build_model() remain importable for API compatibility but emit LegacyMipDeprecationWarning (a DeprecationWarning, shown for calls from scripts/notebooks and under pytest):

  • solve_mip(pb, time_limit=60, driver="auto", debug=False) delegates to the operational MILP (solve_with_operational_milp()). It keeps the 1.0.1 result contract (objective, assignments, has_solution, outcome, solver_status, termination_condition, solver_error, warnings; plus production, warm_start and solver), never raises for infeasible models, time limits or solver errors, and raises SolverUnavailable only for a missing solver and ValueError for an unknown driver. Legacy driver names map to operational solvers via operational_solver_for_driver() (auto → Gurobi when installed, else HiGHS; HiGHS drivers → highs; gurobi / gurobi-appsi / gurobi-direct → gurobi / appsi_gurobi / gurobi_direct). Objectives are operational MILP objectives, not comparable with 1.0.0 solve_mip values.

  • build_model(pb) still builds the legacy day-level Pyomo model, which no solver uses. Its loader-buffer constraint demands −batch m³ of buffer in the first shift, so it is infeasible for every scenario with a loader role (every bundled example, also at v1.0.0). Use fhops.model.milp.operational.build_operational_model() instead.

Optimization layer (MIP builders, heuristics, constraints).

fhops.optimization.build_model(pb)[source]

Build the legacy FHOPS day-level MIP model (deprecated).

Deprecated since version 1.0.1: Emits fhops.optimization.mip.deprecation.LegacyMipDeprecationWarning. The model is infeasible whenever a block’s harvest system has a loader role with machines (the system_loader_buffer rows demand −batch m³ of buffer in the first shift), which includes the bundled tiny7, small21 and med42 examples. Use the operational MILP (fhops.model.milp.operational.build_operational_model(), fhops.model.milp.driver.solve_operational_milp()).

Parameters:

pb (Problem) – fhops.scenario.contract.Problem produced by Problem.from_scenario. The helper must include the full shift list (pb.shifts) so the model can build the (machine, block, day, shift) assignment tensor.

Returns:

Fully constructed model containing decision variables for assignments/production, optional transition binaries (when mobilisation or transition penalties are enabled), and the objective/constraints described in the FHOPS roadmap.

Return type:

pyomo.ConcreteModel

Notes

The builder purposely mirrors the documented objective weights:

  • ObjectiveWeights.production – coefficient for total production.

  • ObjectiveWeights.mobilisation – coefficient for mobilisation spend derived from transition binaries.

  • ObjectiveWeights.transitions – optional penalty for the number of transitions itself.

  • ObjectiveWeights.landing_surplus – enables soft landing-capacity overages using surplus variables.

Any change to this function should be reflected in docs/howto/thesis_eval.rst and the MIP section of the API docs.

Scenario.locked_assignments are fixed on the assignment variables: a lock without shift_id fixes every shift of its day, a lock with shift_id fixes only that slot. Scenario.initial_state is not modelled by this legacy builder (a UserWarning is emitted); use the operational MILP (fhops.model.milp.driver.solve_operational_milp(), fhops solve-mip-operational) when carrying state into a horizon.

fhops.optimization.solve_mip(pb, time_limit=60, driver='auto', debug=False)[source]

Solve pb with the operational MILP (deprecated legacy entry point).

Deprecated since version 1.0.1: Emits fhops.optimization.mip.deprecation.LegacyMipDeprecationWarning and delegates to solve_with_operational_milp() (i.e. fhops.model.milp.driver.solve_operational_milp()). Until 1.0.1 this function solved the legacy day-level MIP, which is infeasible for every scenario with a loader role (#124, #127). Call fhops.model.milp.driver.solve_operational_milp() directly.

Parameters:
  • pb (Problem) – fhops.scenario.contract.Problem built with Problem.from_scenario.

  • time_limit (int) – Solver wall-clock limit in seconds (default 60).

  • driver (str) – Legacy driver name, mapped by operational_solver_for_driver(): "auto" (Gurobi when installed, otherwise — or after a Gurobi solver error — HiGHS), "highs" / "highs-appsi" / "appsi" / "highs-exec" / "exec" (HiGHS), "gurobi", "gurobi-appsi" (appsi_gurobi) or "gurobi-direct" (gurobi_direct).

  • debug (bool) – Stream the solver log to stdout and print the selected solver.

Returns:

Same contract as in 1.0.1 (#124); the function never raises because a model is infeasible, a limit was reached without an incumbent, or an available solver failed. Keys:

objective (float | None)

Operational MILP objective of the loaded solution (not comparable with 1.0.0 legacy objectives); None without a feasible solution. A solve stopped by a limit that holds a feasible incumbent reports that incumbent.

assignments (DataFrame)

Columns machine_id, block_id, day, shift_id, assigned, production; one row per assigned slot or slot with production > 1e-6. Empty (same columns) without a solution.

has_solution (bool)

True when a feasible solution was loaded into the model.

outcome (str)

"optimal", "feasible" (incumbent at a limit), "infeasible", "no_solution" (limit reached without an incumbent) or "error".

solver_status, termination_condition (str)

Pyomo solver status and termination condition.

solver_error (str | None)

Set when the solver failed rather than proving infeasibility.

warnings (list[str])

Driver notes (e.g. a Gurobi failure under driver="auto" before the HiGHS retry) followed by the operational driver’s lock warnings.

production (float), warm_start (dict), solver (str)

Added by the delegation (see solve_with_operational_milp()).

Return type:

dict

Raises:
  • SolverUnavailable – When the requested solver is not installed (or, for driver="auto", neither Gurobi nor HiGHS is available).

  • ValueError – For an unknown driver name.

Warns:

LegacyMipDeprecationWarning – On every call.

Examples

>>> res = solve_mip(pb, time_limit=60)
>>> # preferred:
>>> from fhops.model.milp.driver import solve_operational_milp
>>> from fhops.optimization.operational_problem import build_operational_problem
>>> ctx = build_operational_problem(pb)
>>> res = solve_operational_milp(ctx.bundle, time_limit=60, context=ctx)

Pyomo builder for the legacy FHOPS day-level MIP (deprecated since 1.0.1, #127).

The builder is kept importable for API compatibility only. It is infeasible for every scenario whose harvest system has a loader role with machines (see fhops.optimization.mip.deprecation), and no FHOPS solver uses it any more: solve_mip, fhops solve-mip, fhops benchmark and the ILS hybrid step all run the operational MILP (fhops.model.milp).

fhops.optimization.mip.builder.build_model(pb)[source]

Build the legacy FHOPS day-level MIP model (deprecated).

Deprecated since version 1.0.1: Emits fhops.optimization.mip.deprecation.LegacyMipDeprecationWarning. The model is infeasible whenever a block’s harvest system has a loader role with machines (the system_loader_buffer rows demand −batch m³ of buffer in the first shift), which includes the bundled tiny7, small21 and med42 examples. Use the operational MILP (fhops.model.milp.operational.build_operational_model(), fhops.model.milp.driver.solve_operational_milp()).

Parameters:

pb (Problem) – fhops.scenario.contract.Problem produced by Problem.from_scenario. The helper must include the full shift list (pb.shifts) so the model can build the (machine, block, day, shift) assignment tensor.

Returns:

Fully constructed model containing decision variables for assignments/production, optional transition binaries (when mobilisation or transition penalties are enabled), and the objective/constraints described in the FHOPS roadmap.

Return type:

pyomo.ConcreteModel

Notes

The builder purposely mirrors the documented objective weights:

  • ObjectiveWeights.production – coefficient for total production.

  • ObjectiveWeights.mobilisation – coefficient for mobilisation spend derived from transition binaries.

  • ObjectiveWeights.transitions – optional penalty for the number of transitions itself.

  • ObjectiveWeights.landing_surplus – enables soft landing-capacity overages using surplus variables.

Any change to this function should be reflected in docs/howto/thesis_eval.rst and the MIP section of the API docs.

Scenario.locked_assignments are fixed on the assignment variables: a lock without shift_id fixes every shift of its day, a lock with shift_id fixes only that slot. Scenario.initial_state is not modelled by this legacy builder (a UserWarning is emitted); use the operational MILP (fhops.model.milp.driver.solve_operational_milp(), fhops solve-mip-operational) when carrying state into a horizon.

Deprecated solve_mip entry point (delegates to the operational MILP since 1.0.1, #127).

This module backs solve_mip() and the fhops solve-mip / fhops benchmark CLI commands. Since FHOPS 1.0.1 (#127) they solve the operational MILP (fhops.model.milp.driver.solve_operational_milp(), the formulation documented in the SoftwareX paper) through solve_with_operational_milp(); the legacy --driver names are mapped to operational solver names by operational_solver_for_driver().

The legacy day-level MIP (fhops.optimization.mip.builder.build_model()) is infeasible for every scenario with a loader role (see fhops.optimization.mip.deprecation). Its 1.0.1 (#124) solve path is kept as the private _solve_legacy_mip to reproduce that finding; no public entry point uses it.

exception fhops.optimization.mip.highs_driver.SolverUnavailable[source]

Bases: RuntimeError

Raised when a requested solver backend is not installed or cannot be created.

This is the only solver-related exception solve_mip() raises; failures of an available solver are reported through the result’s solver_error instead.

fhops.optimization.mip.highs_driver.operational_solver_for_driver(driver)[source]

Map a legacy MIP driver name to the operational MILP solver name(s) to try.

Parameters:

driver (str) – Legacy driver name (case-insensitive): auto, highs, appsi, highs-appsi, exec, highs-exec, gurobi, gurobi-appsi or gurobi-direct.

Returns:

SolverFactory names in the order they are tried: ("gurobi", "highs") for auto (Gurobi when installed, otherwise or after a Gurobi failure HiGHS — the 1.0.0 auto rule); ("highs",) for every HiGHS driver (seeded solves still go through appsi_highs, see fhops.model.milp.driver.solve_operational_milp()); ("gurobi",), ("appsi_gurobi",) or ("gurobi_direct",) for the Gurobi drivers.

Return type:

tuple[str, …]

Raises:

ValueError – For an unknown driver name.

fhops.optimization.mip.highs_driver.solve_mip(pb, time_limit=60, driver='auto', debug=False)[source]

Solve pb with the operational MILP (deprecated legacy entry point).

Deprecated since version 1.0.1: Emits fhops.optimization.mip.deprecation.LegacyMipDeprecationWarning and delegates to solve_with_operational_milp() (i.e. fhops.model.milp.driver.solve_operational_milp()). Until 1.0.1 this function solved the legacy day-level MIP, which is infeasible for every scenario with a loader role (#124, #127). Call fhops.model.milp.driver.solve_operational_milp() directly.

Parameters:
  • pb (Problem) – fhops.scenario.contract.Problem built with Problem.from_scenario.

  • time_limit (int) – Solver wall-clock limit in seconds (default 60).

  • driver (str) – Legacy driver name, mapped by operational_solver_for_driver(): "auto" (Gurobi when installed, otherwise — or after a Gurobi solver error — HiGHS), "highs" / "highs-appsi" / "appsi" / "highs-exec" / "exec" (HiGHS), "gurobi", "gurobi-appsi" (appsi_gurobi) or "gurobi-direct" (gurobi_direct).

  • debug (bool) – Stream the solver log to stdout and print the selected solver.

Returns:

Same contract as in 1.0.1 (#124); the function never raises because a model is infeasible, a limit was reached without an incumbent, or an available solver failed. Keys:

objective (float | None)

Operational MILP objective of the loaded solution (not comparable with 1.0.0 legacy objectives); None without a feasible solution. A solve stopped by a limit that holds a feasible incumbent reports that incumbent.

assignments (DataFrame)

Columns machine_id, block_id, day, shift_id, assigned, production; one row per assigned slot or slot with production > 1e-6. Empty (same columns) without a solution.

has_solution (bool)

True when a feasible solution was loaded into the model.

outcome (str)

"optimal", "feasible" (incumbent at a limit), "infeasible", "no_solution" (limit reached without an incumbent) or "error".

solver_status, termination_condition (str)

Pyomo solver status and termination condition.

solver_error (str | None)

Set when the solver failed rather than proving infeasibility.

warnings (list[str])

Driver notes (e.g. a Gurobi failure under driver="auto" before the HiGHS retry) followed by the operational driver’s lock warnings.

production (float), warm_start (dict), solver (str)

Added by the delegation (see solve_with_operational_milp()).

Return type:

dict

Raises:
  • SolverUnavailable – When the requested solver is not installed (or, for driver="auto", neither Gurobi nor HiGHS is available).

  • ValueError – For an unknown driver name.

Warns:

LegacyMipDeprecationWarning – On every call.

Examples

>>> res = solve_mip(pb, time_limit=60)
>>> # preferred:
>>> from fhops.model.milp.driver import solve_operational_milp
>>> from fhops.optimization.operational_problem import build_operational_problem
>>> ctx = build_operational_problem(pb)
>>> res = solve_operational_milp(ctx.bundle, time_limit=60, context=ctx)
fhops.optimization.mip.highs_driver.solve_with_operational_milp(pb, time_limit=60, driver='auto', debug=False)[source]

Solve the operational MILP for pb using a legacy driver name (no deprecation).

This is the implementation behind solve_mip(), fhops solve-mip and the MIP step of fhops benchmark. It builds the operational problem (fhops.optimization.operational_problem.build_operational_problem()) and calls fhops.model.milp.driver.solve_operational_milp() with solver from operational_solver_for_driver(), time_limit, tee=debug and the operational context. Equivalent to fhops solve-mip-operational SCENARIO --solver <solver> --time-limit <time_limit>.

Parameters:
  • pb (Problem) – fhops.scenario.contract.Problem built with Problem.from_scenario.

  • time_limit (int | None) – Solver wall-clock limit in seconds (default 60; None leaves the solver default).

  • driver (str) – Legacy driver name (see operational_solver_for_driver()).

  • debug (bool) – Stream the solver log to stdout and print the selected solver.

Returns:

The fhops.model.milp.driver.solve_operational_milp() result (objective, production, assignments, has_solution, outcome, solver_status, termination_condition, solver_error, warnings, warm_start) plus solver (the SolverFactory name that produced it). Under driver="auto" a Gurobi run that reports solver_error is retried with HiGHS and the failure is prepended to warnings.

Return type:

dict

Raises:
  • SolverUnavailable – When no candidate solver is available.

  • ValueError – For an unknown driver name (raised before the model is built).

Deprecation notices for the legacy day-level MIP (FHOPS 1.0.1, #127).

The legacy builder (fhops.optimization.mip.builder.build_model()) is infeasible for every scenario whose harvest system has a loader role with machines: its loader-buffer constraint Σ_{≤s} prod_loader ≤ Σ_{<s} prod_prereq − batch cannot hold in the first shift (found in #124; the bundled tiny7, small21 and med42 examples are proven infeasible by HiGHS, also at v1.0.0). The canonical, published formulation is the operational MILP (fhops.model.milp, fhops solve-mip-operational); solve_mip / fhops solve-mip delegate to it and the builder is kept importable only for API compatibility.

exception fhops.optimization.mip.deprecation.LegacyMipDeprecationWarning[source]

Bases: DeprecationWarning

Warning emitted by the deprecated legacy MIP entry points.

Emitted (stacklevel=2) by fhops.optimization.mip.solve_mip() and fhops.optimization.mip.builder.build_model(). It subclasses DeprecationWarning, so Python shows it for calls made from __main__ (scripts, notebooks) and under test runners; the fhops solve-mip CLI prints its own one-line notice instead.

Simulated annealing heuristic for FHOPS.

class fhops.optimization.heuristics.sa.Schedule(plan, matrix=<factory>, mobilisation_cache=<factory>, dirty_machines=<factory>, block_remaining_cache=None, role_remaining_cache=None, dirty_blocks=<factory>, block_slots=<factory>, dirty_slots=<factory>, slot_production=<factory>, repair_state=None, watch_stats=None)[source]

Bases: object

Machine assignment plan storing both dict and array views.

Parameters:
block_remaining_cache: dict[str, float] | None
block_slots: dict[str, list[tuple[int, str]]]
dirty_blocks: set[str]
dirty_machines: set[str]
dirty_slots: set[tuple[str, int, str]]
matrix: dict[str, list[str | None]]
mobilisation_cache: dict[str, MobilisationStats]
plan: dict[str, dict[tuple[int, str], str | None]]
repair_state: RepairState | None
role_remaining_cache: dict[tuple[str, str], float] | None
slot_production: dict[tuple[str, int, str], SlotProduction]
watch_stats: dict[str, Any] | None
fhops.optimization.heuristics.sa.solve_sa(pb, iters=2000, seed=42, operators=None, operator_weights=None, batch_size=None, max_workers=None, cooling_rate=0.999, restart_interval=None, telemetry_log=None, telemetry_context=None, watch_sink=None, watch_interval=None, watch_metadata=None, watch_debug=False, use_local_repairs=False, objective_weight_overrides=None, milp_objective=None, pre_repair=True, commitment_front=0.0, incremental_repair=True, adaptive_cooling=False, cooling_window=None, cooling_initial_acceptance=0.9, cooling_delta=0.1)[source]

Solve the scheduling problem with simulated annealing.

Parameters:
  • pb (fhops.scenario.contract.Problem) – Parsed scenario context describing machines, blocks, and shifts.

  • iters (int, default=2000) – Number of annealing iterations. Higher values increase runtime and solution quality.

  • seed (int, default=42) – RNG seed used for deterministic runs.

  • operators (list[str] | None) – Optional list of operator names to enable (default: all registered operators).

  • operator_weights (dict[str, float] | None) – Optional weight overrides for operators (values <= 0 disable an operator).

  • batch_size (int | None) – When set, sample up to batch_size neighbour candidates per iteration. None or <= 1 keeps the sequential single-candidate behaviour.

  • max_workers (int | None) – Maximum worker threads for evaluating batched neighbours. None/<=1 keeps sequential scoring.

  • cooling_rate (float, default=0.999) – Multiplicative cooling factor applied each iteration (0 < rate < 1). Larger values cool more slowly. Ignored when adaptive_cooling is set.

  • adaptive_cooling (bool, default=False) – Use the Aarts and Van Laarhoven (1985) adaptive temperature schedule instead of geometric cooling. The first cooling_window iterations are a warm-up (every move accepted) that sets the initial temperature from the average uphill move; the temperature is then decremented from the spread of the scores seen. Tunes itself to the scenario.

  • cooling_window (int | None, optional) – Iterations per temperature under adaptive_cooling. None auto-scales to 64 capped at half of iters.

  • cooling_initial_acceptance (float, default=0.9) – Acceptance ratio the adaptive warm-up targets when setting the initial temperature ((0, 1)). Ignored unless adaptive_cooling is set.

  • cooling_delta (float, default=0.1) – Distance from stationarity the adaptive decrement allows (positive). Ignored unless adaptive_cooling is set.

  • restart_interval (int | None, optional) – Number of consecutive non-accepting iterations before restarting from the greedy seed. None auto-scales to max(1000, iters / 5).

  • telemetry_log (str | pathlib.Path | None) – Optional telemetry JSONL path. When provided, solver progress and final metrics are logged.

  • telemetry_context (dict[str, Any] | None) – Additional context merged into telemetry records (scenario metadata, tuner info, etc.).

  • watch_sink (SnapshotSink | None, optional) – Optional callback that receives fhops.telemetry.watch.Snapshot updates for live dashboards. When omitted, no live progress is emitted.

  • watch_interval (int | None, optional) – Iteration interval between snapshot emissions. Defaults to max(1, iters / 200) when a sink is provided.

  • watch_metadata (dict[str, str] | None) – Additional metadata (e.g., scenario/solver labels) attached to each snapshot.

  • watch_debug (bool, default=False) – When True capture sequencing debug stats for watch snapshots (adds overhead).

  • use_local_repairs (bool, default=False) – When True repairs only the slots touched by a candidate before scoring. This is an approximate mode: the dirty-slot repair does not refill every idle slot, so a candidate’s search score can differ from a full evaluation of the same plan. The final schedule is always re-scored with a full repair before reporting.

  • incremental_repair (bool, default=True) – Resume each candidate’s repair from the accepted schedule’s per-slot state at its earliest changed slot instead of repairing the whole horizon. Provably identical to the full repair and faster on large scenarios (~1.15x on large84); it falls back to the full repair automatically in the modes where that equivalence is not guaranteed (batch_size with parallel workers, pre_repair=False, or use_local_repairs=True).

  • objective_weight_overrides (dict[str, float] | None, optional) – Override scenario objective weights (keys: production, mobilisation, transitions, landing_surplus). None keeps the scenario weights, except for the scenarios listed in AUTO_OBJECTIVE_WEIGHT_OVERRIDES (FHOPS Tiny7/Small21: production 1.0, mobilisation 0.2, transitions 0.1, soft landing capacity 0.05), which are applied automatically and recorded in meta (#140).

  • milp_objective (float | None, optional) – Reference MILP objective used for gap reporting (best - MILP) in watch/telemetry output. None skips gap metrics.

  • pre_repair (bool)

  • commitment_front (float)

Returns:

Dictionary with the following keys:

objective (float)

Fresh evaluate_schedule() score of the returned schedule (higher is better); it equals a re-evaluation of assignments.

assignments (pandas.DataFrame)

Assignment matrix with columns machine_id, block_id, day, shift_id, assigned.

meta (dict[str, Any])

Telemetry payload including operators weights, optional operators_stats, and bookkeeping such as proposals or telemetry_run_id.

Return type:

dict