fhops.planning Package
Rolling-horizon replanning utilities that are shared between the CLI and Python callers. Use these helpers to generate iteration plans, slice scenarios into sub-horizons, and execute rolling solves with either the simulated annealing or MILP hooks.
Typical usage:
from fhops.planning import (
comparison_dataframe,
compute_rolling_kpis,
evaluate_rolling_plan,
solve_rolling_plan,
)
from fhops.scenario.io import load_scenario
import pandas as pd
scenario = load_scenario("examples/tiny7/scenario.yaml")
result = solve_rolling_plan(
scenario,
master_days=7, # tiny7 has a 7-day horizon
subproblem_days=4,
lock_days=3,
solver="sa",
sa_iters=200,
sa_seed=123,
)
print(result.metadata, len(result.locked_assignments))
# Full-horizon baseline, e.g. from
# fhops solve-heur examples/tiny7/scenario.yaml --out tmp/tiny7_full_horizon.csv
baseline_df = pd.read_csv("tmp/tiny7_full_horizon.csv")
comparison = compute_rolling_kpis(
scenario,
result,
baseline_assignments=baseline_df,
)
print(comparison.delta_totals.get("total_production_delta"))
# Or use the reporting wrapper that preserves extra metadata and deltas.
comparison = evaluate_rolling_plan(
result,
scenario,
baseline_assignments=baseline_df,
baseline_label="full_sa",
)
print(comparison.deltas.get("total_production_delta"))
# Build a plotting-friendly frame for MASc experiments.
plot_df = comparison_dataframe(comparison, metrics=["total_production", "mobilisation_cost"])
Solver options (MILP)
Pass solver-specific options (threads, gap targets, log files) via mip_solver_options on the
library helper, or set environment variables for backends like Gurobi:
result = solve_rolling_plan(
scenario,
master_days=42,
subproblem_days=21,
lock_days=7,
solver="mip",
mip_solver="gurobi",
mip_time_limit=600,
mip_solver_options={"Threads": 64, "LogFile": "med42.log"},
)
mip_solver_options is also accepted by fhops.planning.get_solver_hook() for direct hook
construction.
Assignments exported by fhops plan rolling (--out-assignments) can be fed directly into
fhops.planning.compute_rolling_kpis() alongside a monolithic baseline DataFrame when you want
KPI deltas without re-running the solver in Python.
Planning utilities beyond single-horizon solves.
This package houses helpers that assemble multi-stage planning workflows (e.g., rolling-horizon replanning). Modules here should provide both library-friendly entry points and CLI wiring so the same orchestration logic can be reused by automation scripts and user-facing commands.
- class fhops.planning.MILPSolver(solver='auto', time_limit=300, solver_options=None, earliness=True, earliness_time_limit=None)[source]
Bases:
objectRolling-horizon solver hook using the operational MILP.
- Parameters:
solver (str) – Pyomo backend to invoke (e.g.,
"highs","gurobi")."auto"(default) uses the driver’s automatic selection, Gurobi when available else HiGHS (resolve_operational_mip_solver()); the resolved name is stored insolverand reported asmip_solverin the run metadata.time_limit (int) – Solve time limit in seconds for each subproblem.
solver_options (Mapping[str, object] | None) – Optional solver-specific options forwarded to Pyomo (e.g.,
{"Threads": 64}for Gurobi).earliness (bool) – Break ties of the window objective in favour of early production (default
True; FHOPS 1.0.1, #141): the driver’s lexicographic earliness stage (fhops.model.milp.driver.solve_operational_milp(),earliness=True) runs after the base solve, so the window’s base objective is unchanged (up to a relative10⁻⁶) while work is not deferred past the lock span. Not applied to windows whose lock span covers the whole window (e.g. the final window, orsub == lock), where timing inside the window does not affect what is locked; a single-window run therefore stays a direct solve.earliness_time_limit (int | None) – Time limit (seconds) of the earliness stage;
None(default) usestime_limit, so a window may take up to twicetime_limit.
Notes
The hook merges the
locked_assignmentsit receives withscenario.locked_assignments(the operational MILP enforces them as equality constraints), keeps only rows withassigned = 1, preservesshift_idand the plannedproduction(so carry-forward andcompute_rolling_kpis()replay the MILP plan, not the full production rate), and records the wall-clock runtime of model build + solve inSolverOutput.runtime_s. Solver status and termination condition are reported as warnings (solver_status=…,termination_condition=…), followed by the driver’ssolver_error(assolver_error=<text>) and each entry of itswarningslist (e.g. locks the MILP pinned to zero or ignored), without duplicates;run_rolling_horizon()copies them into the iteration warnings.A window without a solution returns
SolverOutput(has_solution=False, assignments=[], objective=None): the driver reported no solution (has_solutionfalse, orobjective is None: time limit before an incumbent, infeasible model) or a solver failure (outcome="error", e.g. an unknown or unlicensed solver; the driver reports these instead of raising since #139), whose text is copied toSolverOutput.error.run_rolling_horizon()then applies its no-solution policy. Exceptions raised while building the window problem or model (FHOPS defects, invalid data) are not caught (FHOPS 1.0.1, #141; before, every exception became a no-solution window).The window objective (
SolverOutput.objective) is always the base objective; the earliness stage is reported as anearliness=<status> value=<E> ...warning.- available()[source]
Return
TruewhenSolverFactory(self.solver)reports the backend as available.- Return type:
- name = 'mip'
- class fhops.planning.RollingCarryState(through_day, remaining_work, initial_state=None)[source]
Bases:
objectBoundary state carried into a rolling-horizon window (FHOPS 1.0.1, #92).
Produced by
carry_forward_state()from a deterministic replay of the stitched locked plan against the base scenario, and consumed byslice_scenario_for_window().- Parameters:
- through_day
Last base-scenario day (inclusive) covered by the replay; the next window starts at
through_day + 1.- Type:
- remaining_work
block_id -> m³terminal volume still to deliver afterthrough_day(the sequencing tracker’sremaining_work; values<= 1e-6are reported as0.0). Every base block is present; finished blocks map to0.0. The slicer uses it as the window’sBlock.work_required.
- initial_state
fhops.scenario.contract.ScenarioInitialStatefor the next window, orNonewhen every entry is at its default. Per explicit-system block it listsrole_remaining(capped at the block’s remaining terminal volume; omitted when equal to it, the contract default),staged_inventory(omitted when zero, and for terminal roles, whose output is delivered volume rather than staged input) androle_shift_counts(omitted when zero); per machine it listslast_block_id(the block of the machine’s last locked assignment, ordered by(day, shift_id), falling back to the baseinitial_state).- Type:
fhops.scenario.contract.models.ScenarioInitialState | None
- initial_state: ScenarioInitialState | None = None
- through_day: int
- class fhops.planning.RollingHorizonConfig(scenario, master_days, subproblem_days, lock_days, start_day=1)[source]
Bases:
objectConfiguration for a rolling-horizon planning run.
- Parameters:
scenario (fhops.scenario.contract.models.Scenario) – Validated scenario to slice into rolling subproblems.
master_days (int) – Total number of days to cover with locked-in plans (e.g., 84 or 112).
subproblem_days (int) – Length of each optimisation window. Must be >=
lock_days.lock_days (int) – Number of days to freeze after each solve. Typically smaller than
subproblem_days.start_day (int) – One-indexed day in the base scenario where the rolling window begins.
- lock_days: int
- master_days: int
- scenario: Scenario
- start_day: int = 1
- subproblem_days: int
- exception fhops.planning.RollingInfeasibleError(message, *, iteration_index=None, partial_result=None)[source]
Bases:
RuntimeErrorRaised when a sub-horizon is infeasible or when solver configuration is invalid.
- Parameters:
message (str) – Human-readable reason (names the iteration when raised inside the rolling loop).
iteration_index (int | None) – Zero-based iteration that failed, or
Nonewhen the error is not tied to one.partial_result (RollingPlanResult | None) –
RollingPlanResultwith the assignments locked and iterations recorded before the failure (including the failing iteration’s record when it was solved), orNone. Set byrun_rolling_horizon()so callers (e.g. the CLI) can still export partial outputs.
- Return type:
None
- class fhops.planning.RollingIterationPlan(iteration_index, start_day, horizon_days, lock_days)[source]
Bases:
objectMetadata for a single rolling-horizon iteration.
- iteration_index
Zero-based iteration counter.
- Type:
- start_day
One-indexed start day in the base scenario.
- Type:
- horizon_days
Sub-horizon length (days) solved in this iteration.
- Type:
- lock_days
Days to freeze after the solve before advancing the window.
- Type:
- property end_day: int
Inclusive end day of the subproblem (in base-scenario coordinates).
- horizon_days: int
- iteration_index: int
- lock_days: int
- start_day: int
- class fhops.planning.RollingIterationSummary(iteration_index, start_day, horizon_days, lock_days, locked_assignments, objective=None, runtime_s=None, warnings=None, remaining_work_start=None, status='solved', has_solution=True, planned_delivered=None, locked_delivered=None, empty=False, locked_production=None, error=None)[source]
Bases:
objectOutcome summary for a single rolling-horizon iteration.
- Parameters:
iteration_index (int)
start_day (int)
horizon_days (int)
lock_days (int)
locked_assignments (int)
objective (float | None)
runtime_s (float | None)
remaining_work_start (float | None)
status (str)
has_solution (bool)
planned_delivered (float | None)
locked_delivered (float | None)
empty (bool)
locked_production (float | None)
error (str | None)
- iteration_index
Zero-based iteration counter.
- Type:
- start_day
One-indexed day in the base scenario where the sub-horizon begins.
- Type:
- horizon_days
Number of days solved in this iteration (sub-horizon length).
- Type:
- lock_days
Number of leading days frozen into the master plan after this solve.
- Type:
- locked_assignments
Count of
fhops.scenario.contract.models.ScheduleLockentries injected into the master plan from this iteration (base-scenario coordinates).- Type:
- objective
Solver objective value (units match the chosen solver) or
Nonewhen not reported (and alwaysNonefor windows without a solution). Window objectives include the leftover penalty on the carried remaining volume, so they are not comparable across windows or with FHOPS 1.0.0 per-window objectives; score runs on the stitched plan instead.- Type:
float | None
- runtime_s
Wall-clock runtime in seconds, if the solver provides it.
- Type:
float | None
- warnings
Warnings surfaced by the solver hook (e.g., termination condition) plus the orchestrator’s no-solution / skipped / empty-window notices;
Nonewhen there are none.
- remaining_work_start
Terminal volume (m³) still to deliver across all base blocks at the start of this window, from the carried-forward state (the base
work_requiredtotal for the first window).Nonewhen not computed (e.g. summaries built by hand).- Type:
float | None
- status
"solved"(the hook returned a plan, possibly empty),"no_solution"(the hook reported no solution: time limit before an incumbent, infeasible window, solver error) or"skipped"(nothing to plan: no blocks, no production rates, or no available shift slots; the solver is not called). Windows that are not"solved"lock no assignments.- Type:
- has_solution
Falseonly forstatus == "no_solution".- Type:
- planned_delivered
Terminal volume (m³) the window’s whole plan delivers when replayed on the window scenario (deterministic playback rule);
0.0for windows that are not"solved".Nonewhen not computed.- Type:
float | None
- locked_delivered
Terminal volume (m³) the locked span of this window delivers in the stitched-plan replay (
remaining_work_startminus the remaining volume after the lock span).Nonewhen not computed.- Type:
float | None
- empty
Truewhen the window had remaining work (> 1e-6m³ over its blocks) but its locked span produces nothing (no locks, or only locks without production:locked_production<= 1e-6) or its plan delivers nothing (e.g. an all-zero time-limited MILP incumbent, a plan that defers all work beyond the lock span, a no-solution or skipped window). A lock span whose blocks only open later in the window is flagged too.- Type:
- locked_production
Machine production (m³, every role) of the window plan’s locked span in the window replay (FHOPS 1.0.1, #141);
0.0for windows that are not"solved".Nonewhen not computed.- Type:
float | None
- error
Solver failure text of a
"no_solution"window whose solver failed (SolverOutput.error), elseNone(#141).- Type:
str | None
- empty: bool = False
- has_solution: bool = True
- horizon_days: int
- iteration_index: int
- lock_days: int
- locked_assignments: int
- start_day: int
- status: str = 'solved'
- class fhops.planning.RollingKPIComparison(rolling_assignments, rolling_kpis, baseline_assignments=None, baseline_kpis=None, delta_totals=None)[source]
Bases:
objectComparison payload capturing rolling vs. baseline KPI metrics.
- Parameters:
- rolling_assignments
DataFrame version of
RollingPlanResult.locked_assignmentssuitable for playback/KPI runs.- Type:
pandas.DataFrame
- rolling_kpis
KPI totals computed from the rolling plan assignments.
- baseline_assignments
Optional baseline schedule (full-horizon heuristic/MIP output) for comparison. An empty frame (no rows) when an empty baseline was supplied.
- Type:
pandas.DataFrame | None
- baseline_kpis
KPI totals computed from
baseline_assignmentswhen provided (zero-delivery KPIs for an empty baseline).- Type:
- delta_totals
Numeric difference
rolling - baselinefor KPI keys present in both payloads.
- baseline_assignments: DataFrame | None = None
- rolling_assignments: DataFrame
- rolling_kpis: KPIResult
- class fhops.planning.RollingPlanComparison(rolling_kpis, baseline_kpis, deltas, metadata)[source]
Bases:
objectBundle containing KPI totals for rolling vs. baseline plans.
- Parameters:
- rolling_kpis
KPI bundle computed from the locked assignments emitted by the rolling planner. Contains scalar totals plus cached shift/day calendars via
fhops.evaluation.metrics.kpis.KPIResult.- Type:
- baseline_kpis
KPI bundle computed from a full-horizon baseline such as a monolithic MILP solve.
Nonewhen no baseline is supplied; an empty baseline yields zero-delivery KPIs.- Type:
KPIResult | None
- deltas
Numeric KPI differences keyed by
<metric>_deltaand<metric>_pct_delta(when a non-zero baseline exists). Only numeric KPI entries are compared.
- metadata
Copy of
fhops.planning.rolling.RollingPlanResult.metadatawith the additionalbaseline_label, assignment counts, and theempty_windows/no_solution_windowsiteration counts (#117) so telemetry exports can capture context.
- rolling_kpis: KPIResult
- class fhops.planning.RollingPlanResult(locked_assignments, iteration_summaries, metadata, warnings=None)[source]
Bases:
objectAggregated result for a rolling-horizon run.
- Parameters:
- locked_assignments
Locked
fhops.scenario.contract.models.ScheduleLockentries rebased to the base scenario (one-indexed days).- Type:
list[fhops.scenario.contract.models.ScheduleLock]
- iteration_summaries
Per-iteration summaries (objective, runtime, warnings, lock span, horizon span).
- Type:
list[fhops.planning.rolling.RollingIterationSummary]
- metadata
Descriptive metadata such as scenario name, master/sub/lock horizon lengths, start day, and solver identifier. Keys are JSON-serialisable so telemetry exporters can persist them.
- warnings
Optional warnings accumulated across the rolling run (slicer notices plus the no-solution / skipped / empty-window notices); empty list when none are present.
- property empty_windows: list[int]
Indices of iterations flagged as empty (
RollingIterationSummary.empty).
- iteration_summaries: list[RollingIterationSummary]
- locked_assignments: list[ScheduleLock]
- class fhops.planning.SolverOutput(assignments, objective=None, runtime_s=None, warnings=None, has_solution=True, error=None)[source]
Bases:
objectReturn type for rolling-horizon solver hooks.
- Parameters:
- assignments
Sequence of
fhops.scenario.contract.models.ScheduleLockentries in sub-horizon coordinates (day 1 maps to the iteration start). Only the firstRollingIterationPlan.lock_dayswill be frozen by the orchestrator.- Type:
collections.abc.Sequence[fhops.scenario.contract.models.ScheduleLock]
- objective
Objective value reported by the solver or
Nonewhen unavailable.- Type:
float | None
- runtime_s
Wall-clock runtime in seconds, when provided by the solver.
- Type:
float | None
- warnings
Optional warnings emitted by the solver (solver status, termination condition, etc.).
- has_solution
Falsewhen the solver produced no solution (time limit before an incumbent, infeasible window, solver error). The orchestrator then ignoresassignmentsand applies the no-solution policy (seerun_rolling_horizon()). Defaults toTrue.- Type:
- error
Solver failure text (e.g. unknown or unlicensed solver) for a window without a solution;
Noneotherwise (FHOPS 1.0.1, #141). Copied toRollingIterationSummary.error.- Type:
str | None
- assignments: Sequence[ScheduleLock]
- has_solution: bool = True
- class fhops.planning.TacticalOperationalScenario(*, name, planning_level='tactical_operational', schema_version='0.1.0', parent=None, overlay_id=None, source_hash=None, economics=<factory>, periods, products, planning_units, harvest_system_options, fleet_capacity=<factory>, facilities, facility_demand=<factory>, initial_inventory=<factory>, transport_arcs=<factory>, external_supply=<factory>, roads=<factory>, road_dependencies=<factory>, block_road_access=<factory>, silviculture_transitions=<factory>, fleet_options=<factory>)[source]
Bases:
BaseModelValidated aggregate planning contract for TOPM-inspired FHOPS scenarios.
The schema uses long-form, dimension-flexible tables. Cross-validation checks all period, product, block, system-option, facility, transport, inventory, and external-supply references.
- Parameters:
name (str)
planning_level (str)
schema_version (str)
parent (str | None)
overlay_id (str | None)
source_hash (str | None)
economics (Economics)
periods (list[PlanningPeriod])
planning_units (list[PlanningUnit])
harvest_system_options (list[HarvestSystemOption])
fleet_capacity (list[FleetCapacity])
facility_demand (list[FacilityDemand])
initial_inventory (list[InitialInventory])
transport_arcs (list[TransportArc])
external_supply (list[ExternalSupply])
roads (list[RoadProject])
road_dependencies (list[RoadDependency])
block_road_access (list[BlockRoadAccess])
silviculture_transitions (list[SilvicultureTransition])
fleet_options (list[FleetOption])
- block_road_access: list[BlockRoadAccess]
- dimension_summary()[source]
Return model-dimension counts used by CLI validation and telemetry.
- economics: Economics
- external_supply: list[ExternalSupply]
- facility_demand: list[FacilityDemand]
- fleet_capacity: list[FleetCapacity]
- fleet_options: list[FleetOption]
- harvest_system_options: list[HarvestSystemOption]
- initial_inventory: list[InitialInventory]
- model_config = {'extra': 'forbid'}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- name: str
- periods: list[PlanningPeriod]
- planning_level: str
- planning_units: list[PlanningUnit]
- road_dependencies: list[RoadDependency]
- roads: list[RoadProject]
- schema_version: str
- silviculture_transitions: list[SilvicultureTransition]
- to_dict()[source]
Return a JSON-compatible round-trip representation of the scenario.
- transport_arcs: list[TransportArc]
- fhops.planning.carry_forward_state(base, locked_assignments, *, through_day)[source]
Replay a stitched locked plan on the base scenario and return the carried-forward state.
- Parameters:
base (Scenario) – Base (unsliced) scenario. Its
initial_state(when set) seeds the replay, so carried state composes with user-supplied state. Not mutated.locked_assignments (Sequence[ScheduleLock]) – Stitched locked plan in base-scenario coordinates (e.g.
RollingPlanResult.locked_assignments). Entries withday > through_dayare ignored.shift_idandproductionare honoured. A lock withoutshift_idis a day-level lock and is replayed in every shift slot of its day in which the machine is available (operational MILP semantics, #125; FHOPS < 1.0.1 replayed it as"S1"on multi-shift days).through_day (int) – Last base day (inclusive) to replay. Must be
>= 0.
- Returns:
Remaining terminal volume per block plus the per-block/per-machine initial state for a window starting at
through_day + 1.- Return type:
RollingCarryState
- Raises:
ValueError – If a day-level lock carries a planned
productionand its day has more than one available shift slot for the machine (the split is ambiguous).
Notes
Production follows the deterministic playback rule: each assignment proposes its lock’s planned
productionwhen set (operational-MILP rolling runs store the MILPprodvalue) andmin(rate, block remaining)otherwise (SA runs, user locks); the sharedfhops.evaluation.sequencing.SequencingTrackercaps it by the role’s remaining output and the staged upstream inventory. The carried state is therefore exactly the statecompute_rolling_kpis()/fhops.evaluation.compute_kpis()report for the same locked plan, and for MILP runs it is the state the window MILP planned. Role-keyed state is only emitted for blocks with an explicitharvest_system_idand for roles of that system (matchingfhops.scenario.contract.validate_initial_state()).
- fhops.planning.comparison_dataframe(comparison, *, metrics=None)[source]
Return a tidy DataFrame of rolling vs. baseline KPIs for plotting.
- Parameters:
- Returns:
DataFrame with columns
metric,rolling,baseline,delta,pct_delta, andbaseline_label. Rows contain one metric per KPI, suitable for plotting or Markdown table generation.- Return type:
pandas.DataFrame
- fhops.planning.compute_rolling_kpis(scenario, result, *, baseline_assignments=None)[source]
Compute KPI totals for a rolling plan and compare them to an optional baseline.
- Parameters:
scenario (Scenario | Problem) – Scenario or
fhops.scenario.contract.Problemdescribing the planning horizon. The helper converts it to aProblemfor KPI evaluation when necessary.result (RollingPlanResult | DataFrame | Sequence[ScheduleLock]) – Rolling plan output returned by
solve_rolling_plan(), or a DataFrame/ScheduleLocksequence of locked assignments exported by the CLI (columns:machine_id,block_id,dayand optionalshift_id).baseline_assignments (DataFrame | Sequence[ScheduleLock] | None) – Optional baseline schedule (full-horizon MILP/SA run) supplied as a Pandas DataFrame or sequence of
fhops.scenario.contract.models.ScheduleLockrows. Required columns mirror the rolling assignments (machine_id,block_id,dayand optionalshift_id). When omitted (None), delta fields remainNone. An empty DataFrame or empty sequence is evaluated as a plan with no assignments (see Notes).
- Returns:
Bundle containing
rolling_assignments(DataFrame),rolling_kpisandbaseline_kpis(when provided), anddelta_totalscontaining<metric>_deltaand<metric>_pct_deltaentries for numeric KPIs.- Return type:
RollingKPIComparison
- Raises:
ValueError – If the rolling plan does not contain any locked assignments (a
RollingPlanResultwithout locks, an empty DataFrame, or an emptyScheduleLocksequence).TypeError – If
baseline_assignmentsis not a DataFrame or sequence ofScheduleLockentries.
Notes
Day-level locks (
shift_idNone) in aRollingPlanResultorScheduleLocksequence are expanded to every shift slot of their day in which the machine is available (carry_forward_state()semantics, #125). DataFrame rows are passed to playback as given (rows withoutshift_idare rejected on multi-shift scenarios).Empty plans never look complete (#108):
An empty rolling plan raises
ValueError(unchanged from v1.0.0) so a failed rolling run cannot be scored silently. To score it as a zero-delivery plan callfhops.evaluation.compute_kpis()with an empty frame directly.An explicitly supplied empty baseline is scored as a zero-delivery plan:
baseline_kpis["total_production"] == 0andremaining_work_totalequals the scenario’s totalwork_required.<metric>_pct_deltaentries are omitted where the baseline value is zero. (v1.0.0 silently dropped an empty baseline and returnedbaseline_kpis=None.)Rows with
assigned <= 0are kept inrolling_assignmentsbut deliver nothing, so a DataFrame whose rows are all unassigned is scored as a zero-delivery plan.
Examples
>>> from fhops.planning import solve_rolling_plan, compute_rolling_kpis >>> scenario = load_scenario("examples/tiny7/scenario.yaml") >>> rolling = solve_rolling_plan(scenario, master_days=7, subproblem_days=7, lock_days=7) >>> comparison = compute_rolling_kpis(scenario, rolling) >>> comparison.rolling_kpis["total_production"] 5000.0 # example value
- fhops.planning.evaluate_rolling_plan(result, scenario, *, baseline_assignments=None, baseline_label='single_horizon')[source]
Compute KPI and playback deltas between a rolling plan and a baseline plan.
- Parameters:
result (RollingPlanResult) – Locked plan and iteration summaries produced by
fhops.planning.solve_rolling_plan()orfhops.planning.run_rolling_horizon().scenario (Scenario | Problem) – Scenario or operational problem used to evaluate the plan (typically the same scenario used to generate
result).baseline_assignments (DataFrame | Sequence[ScheduleLock] | None) – Optional baseline schedule to compare against. Accepts either a schedule DataFrame with
machine_id,block_id,day(and optionalshift_id) columns or a sequence offhops.scenario.contract.models.ScheduleLockentries. Use a monolithic MILP/SA schedule to quantify rolling suboptimality; passNoneto skip baseline deltas. An empty DataFrame/sequence (e.g. a baseline solve that found no solution) is scored as a zero-delivery plan rather than dropped.baseline_label (str) – Label describing the baseline schedule (e.g.,
"full_mip_600s"). This value is threaded into the comparison metadata so telemetry exports remain traceable.
- Returns:
KPI totals for the rolling run, baseline KPIs when provided, per-metric deltas, and merged metadata that includes assignment counts and the
baseline_label.- Return type:
RollingPlanComparison
- Raises:
ValueError – If
resultcontains no locked assignments.TypeError – If
baseline_assignmentsis not a DataFrame or sequence ofScheduleLockitems.
Notes
Empty-plan semantics follow
fhops.planning.compute_rolling_kpis().
- fhops.planning.get_solver_hook(name, *, sa_iters=500, sa_seed=42, mip_solver='auto', mip_time_limit=300, mip_solver_options=None, mip_earliness=True, mip_earliness_time_limit=None)[source]
Resolve a solver hook by name.
- Parameters:
name (str) – Solver identifier (
"sa","mip"/"milp", or"stub").sa_iters (int) – Number of iterations to run when
name == "sa".sa_seed (int) – Random seed passed to the SA hook for deterministic runs.
mip_solver (str) – Pyomo MILP driver to invoke when
nameis"mip"or"milp";"auto"resolves to"highs".mip_time_limit (int) – Solve time limit in seconds for the MILP hook.
mip_solver_options (Mapping[str, object] | None) – Optional solver-specific parameters forwarded to the MILP backend (e.g.,
{"Threads": 64}).mip_earliness (bool) – Earliness tie-break in MILP windows (default
True; seeMILPSolver).mip_earliness_time_limit (int | None) – Time limit of the earliness stage (
None:mip_time_limit).
- Returns:
Callable that consumes a sliced scenario and iteration plan.
- Return type:
IterableSolver
- Raises:
RollingInfeasibleError – If an unsupported solver name is supplied.
- fhops.planning.load_tactical_operational_scenario(yaml_path)[source]
Load and validate a TOPM-inspired tactical–operational scenario.
The loader accepts either inline YAML sections or a
data:mapping from section names to CSV files.topm-mini-style specifications may usefixture_id/specification_version; those aliases are normalized tonameandschema_versionbefore Pydantic validation.- Parameters:
- Return type:
- fhops.planning.rolling_assignments_dataframe(result, *, include_metadata=False, scenario=None)[source]
Return locked assignments as a DataFrame for playback/KPI workflows.
- Parameters:
result (RollingPlanResult) – Rolling plan output produced by
run_rolling_horizon()orsolve_rolling_plan().include_metadata (bool) – When
Trueappend run metadata (scenario, horizons, solver label) to each row so exports remain self-describing.scenario (Scenario | Problem | None) – Optional base scenario (or
Problem). When given, day-level locks (shift_idNone) are expanded to one row per shift slot of the day in which the machine is available, as incarry_forward_state(). Plans returned byrun_rolling_horizon()are already shift-level; this matters for hand-built results.
- Returns:
Columns
machine_id,block_id,day,shift_id,assigned(always1),production(only when locks carry planned production, e.g. MILP rolling runs; m³), and optionally the metadata keys, sorted byday,shift_id,machine_id,block_id.shift_idisNonefor day-level locks whenscenariois omitted.- Return type:
pandas.DataFrame
- Raises:
ValueError – With
scenario: a day-level lock with a plannedproductioncovers several shift slots.
Notes
The resulting frame can be passed directly to
fhops.evaluation.compute_kpis()orfhops.evaluation.playback.run_playback()to evaluate the rolling plan in the same way as a monolithic solve. Playback rejects rows withoutshift_idon multi-shift scenarios (and treats them as"S1"otherwise), so passscenariofor plans with day-level locks. Evaluating the frame against the base scenario reproduces exactly the state carried between windows (seecarry_forward_state()).
- fhops.planning.run_rolling_horizon(config, solver, *, max_iterations=None, solver_name=None, fail_on_empty_window=False, fail_on_no_solution=False)[source]
Execute the rolling-horizon loop with a user-supplied solver hook.
The solver hook is responsible for producing assignments for each subproblem. This orchestrator handles window planning, scenario slicing, lock rebasing, state carry-forward, and aggregation of locked decisions. Only the first
lock_daysof each iteration are frozen; the remainder of the sub-horizon is discarded when rolling forward.Before every iteration after the first, the stitched locked plan is replayed on the base scenario through day
start_day - 1(carry_forward_state()), and the window is sliced with the resulting remaining volumes andinitial_state(slice_scenario_for_window()). The first window uses the basework_requiredandinitial_stateunchanged, so a single-window run (subproblem_days == lock_days == master_days) is a direct solve.- Parameters:
config (RollingHorizonConfig) – Rolling-horizon configuration describing master/sub/lock horizons.
solver (IterableSolver) – Callable that accepts a sliced scenario, the iteration plan, and the window’s locks (
locked_assignments=: the base scenario’s user locks that fall in the window, rebased to window days; identical toscenario.locked_assignments) and returns aSolverOutputwhoseassignmentsare window-dayScheduleLockentries. Hooks should setScheduleLock.shift_id; an assignment without it is a day-level lock and is expanded to one lock per shift slot of that day in which the machine is available (MILP semantics, #125); a day-level assignment with a plannedproductionon a day with several such slots raisesValueError. Hooks report a failed solve withSolverOutput(has_solution=False).max_iterations (int | None) – Optional guard to cap the number of iterations (useful for smoke tests);
Noneruns every window. Must be>= 1when given.solver_name (str | None) – Optional solver label to persist into
RollingPlanResult.metadata.fail_on_empty_window (bool) – When
True, raiseRollingInfeasibleErrorat the first window whose solver returned no solution or that was solved to an empty plan (RollingIterationSummary.empty: its lock span produces nothing or its plan delivers nothing while its blocks hold work), instead of recording it and continuing (FHOPS 1.0.1, #141; before, only windows without a solution raised). Skipped windows never raise. DefaultFalse.fail_on_no_solution (bool) – When
True, raiseRollingInfeasibleErrorat the first window whose solver returned no solution (the pre-#141 meaning offail_on_empty_window). DefaultFalse.
- Returns:
Locked assignments in base-scenario coordinates (
shift_idpreserved; day-level hook output expanded to shift-level locks) plus per-iteration summaries (status,has_solution,planned_delivered,locked_delivered,empty).warningslists slicer notices (e.g. a carriedlast_block_idthat falls outside a window) and the no-solution / skipped / empty-window notices.- Return type:
RollingPlanResult
- Raises:
RollingInfeasibleError – If a user lock lies outside its block’s
earliest_start/latest_finishwindow (checked up front for every lock, independent of the window settings), or, withfail_on_empty_window=True/fail_on_no_solution=True, at the first window that triggers it. The exception’spartial_resultholds the run up to the failure. Any other exception raised inside the loop (e.g. by a custom hook) propagates unchanged with a best-effortpartial_resultattribute attached.
Notes
No-solution policy (default): when the hook returns
has_solution=False(the MILP hook does so for a time limit before any incumbent, an infeasible window, or a solver exception), the iteration is recorded withstatus="no_solution",objective=Noneand a warning; no assignments are locked for its lock span (machines idle; user locks in that span are not applied either), so the carried state is unchanged across the span, and the next window is solved from it. Windows with nothing to plan (no blocks, no production rates, or no available shift slots, e.g. days outside a partialshift_calendar) are not passed to the solver and are recorded withstatus="skipped". A window is flaggedempty(warning, counted insummarize_plan()["empty_windows"]) when its blocks still hold work but it locked no assignments or its plan delivers no volume. Policy warnings are also emitted asUserWarning.
- fhops.planning.slice_scenario_for_window(base, window, locked_assignments=None, *, carry_state=None)[source]
Return a horizon-trimmed scenario for the given iteration window.
The slice rebases day indices so the window start maps to day 1: machine and shift calendars, timeline blackouts, block windows, and locks outside the window are dropped and the rest are shifted. Blocks whose
[earliest_start, latest_finish]window does not overlap the sub-horizon are dropped together with their production rates and mobilisation distances. When the base scenario has ashift_calendarbut none of its entries fall inside the window, the window receives explicitavailable=0entries so it has no shift slots (machines unavailable), exactly like the same days in a direct solve; an empty calendar would otherwise fall back to the timeline shifts orS1and invent capacity (FHOPS 1.0.0 behaviour, fixed in #117).- Parameters:
base (Scenario) – Original scenario to slice. This object is not mutated.
window (RollingIterationPlan) – Iteration window describing the start day and sub-horizon length.
locked_assignments (Sequence[ScheduleLock] | None) – Optional extra locks (base-scenario coordinates) merged with
base.locked_assignments(FHOPS <= 1.0.0 replaced the scenario’s locks instead). Exact duplicates are collapsed; conflicting locks are rejected byScenariovalidation. Only locks inside the window are retained (day-rebased,shift_idpreserved).carry_state (RollingCarryState | None) – Optional
RollingCarryState(seecarry_forward_state()). When given, each block’swork_requiredis set tocarry_state.remaining_work(finished blocks stay with0) andinitial_stateis set tocarry_state.initial_state. When omitted, the basework_requiredandinitial_stateare kept (window 0 / direct-solve behaviour).
- Returns:
A re-validated deep copy with
num_days == window.horizon_days. Initial-state entries for dropped blocks are removed, as arelast_block_idvalues that point at dropped blocks (aUserWarningis emitted for the latter because the boundary move is then free in the window solve).- Return type:
Scenario
- Raises:
RollingInfeasibleError – If a lock inside the window references a block that is outside the window. This only happens for locks outside their block’s
earliest_start/latest_finishwindow, whichrun_rolling_horizon()rejects up front.ValueError – (Pydantic
ValidationError) if the merged locks conflict.
- fhops.planning.solve_rolling_plan(scenario, *, master_days, subproblem_days, lock_days, solver='sa', sa_iters=500, sa_seed=42, mip_solver='auto', mip_time_limit=300, mip_solver_options=None, max_iterations=None, fail_on_empty_window=False, fail_on_no_solution=False, mip_earliness=True, mip_earliness_time_limit=None)[source]
Library-facing helper to execute a rolling-horizon plan.
- Parameters:
scenario (Scenario) – Validated scenario to slice into rolling subproblems.
master_days (int) – Total number of days to lock in across the rolling run. Must satisfy
master_days + start_day - 1 <= scenario.num_days.subproblem_days (int) – Number of days solved per iteration (≥
lock_days).lock_days (int) – Number of leading days to freeze after each iteration (≤
subproblem_days).solver (str) – Solver hook to use (
"sa","mip","milp", or"stub").sa_iters (int) – Simulated annealing iteration budget when
solver == "sa".sa_seed (int) – Random seed for SA runs to keep results deterministic across iterations.
mip_solver (str) – Pyomo MILP driver name when
solveris MILP-backed;"auto"uses the driver’s automatic selection (Gurobi when available, otherwise HiGHS).mip_time_limit (int) – Time limit in seconds for each MILP subproblem solve.
mip_solver_options (Mapping[str, object] | None) – Optional solver-specific parameters forwarded to the MILP backend (e.g.,
{"Threads": 64}).max_iterations (int | None) – Optional guard to cap the number of iterations (
>= 1; useful for smoke tests).fail_on_empty_window (bool) – Raise
RollingInfeasibleErrorat the first window without a solution or solved to an empty plan instead of recording it and continuing (defaultFalse; seerun_rolling_horizon()).fail_on_no_solution (bool) – Raise
RollingInfeasibleErrorat the first window without a solution only (defaultFalse).mip_earliness (bool) – Earliness tie-break in MILP windows (default
True; seeMILPSolver).mip_earliness_time_limit (int | None) – Time limit of the earliness stage in seconds (
None:mip_time_limit).
- Returns:
Locked assignments, per-iteration summaries (with
status/emptyflags), and metadata describing the run.- Return type:
RollingPlanResult
- Raises:
ValueError – If horizon parameters violate basic bounds (e.g., master horizon exceeds scenario length) or
max_iterations < 1.RollingInfeasibleError – If the solver name is unsupported, a user lock lies outside its block’s time window, or a window triggers
fail_on_empty_window/fail_on_no_solution.
- fhops.planning.summarize_plan(result)[source]
Return a JSON-serialisable summary of a rolling-horizon run.
- Parameters:
result (RollingPlanResult) – Rolling plan result with locked assignments and per-iteration summaries.
- Returns:
JSON-serialisable mapping with keys:
iterationsOne record per iteration:
iteration_index,start_day,horizon_days,lock_days,locked_assignments,objective,runtime_s,warnings,remaining_work_start,status,has_solution,planned_delivered,locked_delivered,locked_production,emptyanderror(seeRollingIterationSummary).total_locked_assignmentsNumber of locked assignments in the stitched plan.
empty_windows/no_solution_windows/skipped_windowsCounts of iterations flagged
empty, without a solution, and skipped.empty_window_indices/no_solution_window_indicesThe corresponding iteration indices.
metadata/warningsRun metadata and accumulated warnings.
- Return type:
- fhops.planning.tactical_scenario_dimensions(scenario)[source]
Return model dimension counts for CLI output and telemetry.
- Parameters:
scenario (TacticalOperationalScenario)
- Return type:
- fhops.planning.tactical_scenario_to_dict(scenario)[source]
Serialize a tactical–operational scenario to a JSON-compatible dictionary.
- Parameters:
scenario (TacticalOperationalScenario)
- Return type: