FHOPS’ deterministic operational solver is formulated on a day-shift grid and maximizes weighted delivered production while penalizing leftovers, landing
over-capacity slack, and machine movement costs. The equations below state the FHOPS 1.0.1 model and mirror the implemented Pyomo model in
fhops.model.milp.operational.build_operational_model and the bundle normalization in fhops.model.milp.data.build_operational_bundle.
Problem statement. Given harvest blocks, machine roles, shift calendars, block windows, landing capacities, and harvest-system role prerequisites, choose machine-block assignments and per-shift production quantities to maximize weighted production subject to feasibility and sequencing constraints.
Labels. For traceability, the objective is tagged OBJ, the optional earliness stage OBJ2, the constraint blocks E1–E13, the optional initial state INIT, and the domain declarations D1. Blocks that persist from FHOPS 1.0.0 keep their 1.0.0 labels (E1–E11). Of these, E6 (machine moves), E7 (staged inventory), E8 (activation and head start, now E8a–E8c), E9 (loader truckload threshold, which replaces the 1.0.0 batching variables) and E11 (landing capacity per shift slot) were reformulated in 1.0.1. E12 (remaining output), E13 (locked assignments), INIT, and OBJ2 are new.
Sets and indices.
\(m \in \mathcal{M}\): machines.
\(b \in \mathcal{B}\): blocks.
\(s=(d,\sigma) \in \mathcal{S}\): shift slots indexed by day \(d \in \mathcal{D}\) and shift label \(\sigma\), ordered by day and, within a day, by the scenario’s shift order; \(\operatorname{prev}(s)\) is the preceding slot (the previous shift of the same day when there is one) and \(s'\prec s\) means slot \(s'\) precedes \(s\).
\(\mathcal{B}^{\text{seq}} \subseteq \mathcal{B}\): blocks with an explicit harvest system (
harvest_system_id); blocks in \(\mathcal{B}\setminus\mathcal{B}^{\text{seq}}\) carry no sequencing obligations.\(\mathcal{R}_b\): ordered machine roles required by the harvest system assigned to block \(b\in\mathcal{B}^{\text{seq}}\) (\(\mathcal{R}_b=\emptyset\) otherwise).
\(\mathcal{L}\): landings.
\(\mathcal{P}^{\text{inv}} \subseteq \{(r,b): r \in \mathcal{R}_b\}\): role-block pairs with upstream prerequisites.
\(\mathcal{P}^{\text{stg}} = \{(u,b): u \in \mathcal{U}_{r,b} \text{ for some } (r,b)\in\mathcal{P}^{\text{inv}}\}\): upstream role-block pairs whose output is staged for downstream roles; \(\mathcal{N}_{u,b} = \{r: u\in\mathcal{U}_{r,b}\}\) are the downstream roles of \(u\) on block \(b\).
\(\mathcal{P}^{\text{hs}} \subseteq \mathcal{P}^{\text{inv}}\): pairs with a head start of \(\beta_{r,b}>0\) shifts (
role_headstart_shifts).\(\mathcal{P}^{\text{load}} \subseteq \{(r,b): r \in \mathcal{R}_b\}\): loader role-block pairs; \(\mathcal{P}^{\text{thr}} = \{(r,b)\in\mathcal{P}^{\text{load}}\cap\mathcal{P}^{\text{inv}}: q^{\text{batch}}_{b}>0,\ W_b>0\}\): loaders subject to the truckload threshold.
\(\mathcal{P}^{\text{act}} = \mathcal{P}^{\text{hs}} \cup \mathcal{P}^{\text{thr}}\): role-block pairs whose production is gated by an activation binary.
\(\mathcal{P}^{\text{cap}} \subseteq \{(r,b): r \in \mathcal{R}_b\setminus\mathcal{T}_b\}\): non-terminal pairs with a per-slot remaining-volume cap; it contains every non-terminal pair of systems with a role feeding several roles or with several terminal roles, and, in the other systems, the pairs whose carried-in state violates \(R_{r,b} + \sum_{v\in\pi_{r,b}} \bar{I}_{v,b} \le W_b\) (\(\pi_{r,b}\): \(r\) and the non-terminal roles on its path to the terminal role). The cap is implied by the other constraints for all remaining pairs, and \(\mathcal{P}^{\text{cap}}=\emptyset\) without an initial state in linear and joining systems.
\(\mathcal{S}^{\text{tail}}_b \subseteq \mathcal{S}\), \(b\) with a loader in \(\mathcal{P}^{\text{thr}}\) and \(W_b > q^{\text{batch}}_b\): slots in which the remaining block volume can have fallen to one truckload, i.e. \(\bar{D}_{b,s} > W_b - q^{\text{batch}}_b\), where \(\bar{D}_{b,s} = \min\{W_b, \sum_{s'\prec s}\sum_{t\in\mathcal{T}_b}\sum_{m\in\mathcal{M}(t)} A_{m,s'}\mathbf{1}^{\text{window}}_{b,d(s')}\bar{p}_{mb}\}\) bounds the terminal output delivered before \(s\).
\(s_1 \in \mathcal{S}\): first shift slot of the horizon.
\(\mathcal{M}^{0} \subseteq \mathcal{M}\) (INIT): machines with a known initial block \(b^{0}_m\) (optional initial state; empty by default); machines in \(\mathcal{M}\setminus\mathcal{M}^{0}\) start unplaced.
\(\mathcal{K}\) (E13): locked assignments \(k=(m_k,b_k,d_k,\sigma_k)\), where \(\sigma_k\) is a shift label or empty (whole day); \(\mathcal{S}_k = \{s=(d_k,\sigma) \in \mathcal{S} : \sigma_k \text{ empty or } \sigma=\sigma_k\}\) (empty by default). \(\mathcal{K}_{b,s} = \{m_k: k\in\mathcal{K},\ b_k=b,\ s\in\mathcal{S}_k,\ \chi_k A_{m_k,s}=1\}\) are the machines locked to block \(b\) in slot \(s\).
Parameters.
\(\bar{p}_{mb}\): production rate for machine \(m\) on block \(b\) (units per shift).
\(W_b\): required total block production volume.
\(A_{m,s} \in \{0,1\}\): machine availability for shift \(s\): \(A_{m,s}=0\) when the machine’s day or shift calendar marks it unavailable or when \(s\) falls in a timeline blackout window, 1 otherwise. Blackouts are fleet-wide: on every day of a blackout window every machine is unavailable in every slot of that day (all slots of \(\mathcal{S}\) on the day and the machine’s own shift-calendar shifts), also when only some machines have shift-calendar entries.
\(\mathbf{1}^{\text{window}}_{b,d} \in \{0,1\}\): block window indicator (1 if day \(d\) is within block \(b\) window).
\(\omega^{\text{prod}},\omega^{\text{mob}},\omega^{\text{trans}},\omega^{\text{land}}\): objective weights.
\(\delta_{m,b',b} \ge 0\): mobilization cost when machine \(m\) moves from block \(b'\) to block \(b \ne b'\); \(c_{m,b',b} = \omega^{\text{mob}}\delta_{m,b',b} + \omega^{\text{trans}}\) is the weighted cost of the move.
\(C_{\ell}\): assignment capacity of landing \(\ell\), the number of machines that may work its blocks concurrently in one shift slot (
Landing.daily_capacity); \(K_{\ell} = |\mathcal{M}| - C_{\ell}\) bounds the machines beyond capacity in a slot.\(\ell(b)\): landing associated with block \(b\).
\(\mathcal{U}_{r,b}\): upstream roles that must feed role \(r\) on block \(b\).
\(B_{r,b}\): head-start buffer volume that every upstream role of \(r\) must have staged before \(r\) may produce on block \(b\): \(B_{r,b}=\beta_{r,b}\sum_{u\in\mathcal{U}_{r,b}}\sum_{m\in\mathcal{M}(u)}\bar{p}_{mb}\) for a head start of \(\beta_{r,b}\) shifts (
role_headstart_shifts, 0 by default). When no machine of an upstream role has a positive rate on \(b\), the role’s own capacity is used instead, \(B_{r,b}=\beta_{r,b}\,Q_{r,b}\). For a join the buffer, computed from the summed rates of all upstream roles, is required of each upstream role (E8b), so a slower upstream role needs more than \(\beta_{r,b}\) shifts to stage it.\(Q_{r,b}=\sum_{m\in\mathcal{M}(r)}\bar{p}_{mb}\): role production capacity per shift (1 when the role has no machine with a positive rate on \(b\); used for the activation linearization).
\(q^{\text{batch}}_{b}\): truckload (loader batch) volume of block \(b\)’s harvest system (
loader_batch_volume_m3, 30 m³ by default).\(\mathcal{T}_b \subseteq \mathcal{R}_b\): terminal roles for block \(b\) (roles credited in block completion objective terms).
\(\bar{I}_{u,b} \ge 0\) (INIT): initial staged volume output by role \(u\) on block \(b\) and not yet consumed downstream (optional initial state
staged_inventory; 0 by default).\(R^{0}_{r,b} \ge 0\) (INIT): carried-in remaining volume role \(r\) may still output on block \(b\) (optional initial state
role_remaining; \(W_b\) by default).\(R_{r,b} = \min(R^{0}_{r,b}, W_b)\): remaining volume role \(r\) may output on block \(b\) (every role handles the same wood, so no role can output more than the block still holds).
\(b^{0}_m\) (INIT): block machine \(m\in\mathcal{M}^{0}\) occupied in its last worked slot before the horizon (optional initial state
last_block_id).
Decision variables.
\(x_{m,b,s} \in \{0,1\}\): 1 if machine \(m\) is assigned to block \(b\) in shift \(s\).
\(p_{m,b,s} \ge 0\): production by machine \(m\) on block \(b\) in shift \(s\).
\(z_{r,b,s} \ge 0\): aggregated role-level production for role \(r\) on block \(b\) in shift \(s\).
\(y_{m,b',b,s} \ge 0\), \(b' \ne b\): machine \(m\) moves in slot \(s\) from its position \(b'\) (the block of its last worked slot before \(s\), or \(b^{0}_m\)) to block \(b\), which it works in \(s\).
\(\eta_{m,b,s} \ge 0\): machine \(m\) keeps position \(b\) through slot \(s\) (it is idle or works \(b\) again).
\(\phi_{m,b,s} \ge 0\), \(m\notin\mathcal{M}^{0}\): the first worked slot of machine \(m\) is \(s\), on block \(b\); \(\nu_{m,s} \ge 0\), \(m\notin\mathcal{M}^{0}\): machine \(m\) has not worked up to and including \(s\) (both \(\equiv 0\) for \(m\in\mathcal{M}^{0}\)).
\(\pi_{m,b,s} = \eta_{m,b,s} + \sum_{b'\ne b} y_{m,b',b,s} + \phi_{m,b,s}\): machine \(m\) holds position \(b\) after slot \(s\) (notation); \(\pi_{m,b,\operatorname{prev}(s_1)} := \mathbf{1}[m\in\mathcal{M}^{0},\, b=b^{0}_m]\) and \(\nu_{m,\operatorname{prev}(s_1)} := \mathbf{1}[m\notin\mathcal{M}^{0}]\).
\(I^{\text{start}}_{u,b,s} \ge 0\), \((u,b)\in\mathcal{P}^{\text{stg}}\): output of upstream role \(u\) on block \(b\) staged for its downstream roles at the start of shift \(s\).
\(I_{u,b,s} \ge 0\): the same staged volume at the end of shift \(s\).
\(g_{r,b,s} \in \{0,1\}\), \((r,b)\in\mathcal{P}^{\text{act}}\): role activation indicator; it gates production only.
\(h_{r,b,s} \in \{0,1\}\), \((r,b)\in\mathcal{P}^{\text{hs}}\): 1 only if every upstream role of \(r\) on block \(b\) has output its whole carried-in remaining volume before slot \(s\) (the buffer can no longer grow and is waived).
\(\lambda_{b,s} \in \{0,1\}\), \(s\in\mathcal{S}^{\text{tail}}_b\): 1 only once the remaining volume of block \(b\) is at most one truckload (selects the active term of the loader threshold).
\(D_{b,s} = \sum_{t\in\mathcal{T}_b}\sum_{s'\preceq s} z_{t,b,s'}\): terminal output delivered on block \(b\) up to and including slot \(s\) (notation for a cumulative sum; \(D_{b,\operatorname{prev}(s_1)} := 0\)).
\(L_b \ge 0\): leftover unmet block volume slack.
\(S_{\ell,s,k} \in [0,1]\), \(k = 1,\dots,K_{\ell}\): unit landing surplus slack for the \(k\)-th machine beyond capacity on landing \(\ell\) in slot \(s\) (only when \(\omega^{\text{land}} > 0\)).
Objective (OBJ).
FHOPS maximizes weighted terminal production and subtracts penalty terms:
The last line charges every move of a machine (E6): working a block other than its position, including the move from \(b^{0}_m\) into the machine’s first worked slot. Staying on a block costs nothing, and a machine without initial block (\(m\notin\mathcal{M}^{0}\)) moves for free into its first worked slot.
For blocks without terminal roles (\(\mathcal{T}_b=\emptyset\), in particular blocks outside \(\mathcal{B}^{\text{seq}}\)) the production reward and the block balance use the machine-level sum \(\sum_{m}\sum_{s} p_{m,b,s}\) in place of \(\sum_{r\in\mathcal{T}_b}\sum_s z_{r,b,s}\).
Earliness tie-break (OBJ2; optional, default in rolling-horizon windows). OBJ does not depend on when work is done inside the horizon, so plans that shift production between slots tie. In a rolling-horizon window, a tied optimum may defer work past the lock span and lock idle days. With the earliness option, a second stage maximizes the production-weighted earliness
where \(k_s\in\{0,\dots,|\mathcal{S}|-1\}\) is the position of slot \(s\) in the slot order. This stage keeps every constraint of E1–E13 and
adds \(\text{OBJ}\ge z_1-\tau\), where \(z_1\) is the OBJ value of the stage-1 solution and \(\tau=10^{-6}\max(1,|z_1|)\). Stage 2 is warm-started
from the stage-1 solution, so its returned plan has \(\text{OBJ}\ge z_1-\tau\). If stage 1 is optimal, the returned plan is therefore optimal for OBJ within
\(\tau\), which is 100 times tighter than HiGHS’s default relative MIP gap (\(10^{-4}\)). The reported objective is OBJ; \(E\) is reported
separately. A single weighted objective \(\text{OBJ}+\varepsilon E\) is not used, for two reasons. First, production is continuous and the data are
arbitrary reals, so no data-independent \(\varepsilon>0\) is guaranteed to stay below the smallest positive OBJ difference between plans: for any
\(\varepsilon\) there are data for which the weighted optimum is not OBJ-optimal. Second, an \(\varepsilon\) small enough to be harmless in practice
(\(\varepsilon E\ll 10^{-4}|z_1|\)) is below the solver’s relative gap, so the solver would stop before it acts on the tie-break. The cost is a second
solve. Rolling-horizon MILP windows enable the option by default, except windows whose lock span covers the whole window. Standalone solves
(fhops solve-mip-operational, solve_operational_milp) do not enable it by default, because the published single-horizon optimum and its solve time stay
unchanged; --earliness or earliness=True turns it on.
Constraints.
Machine assignment feasibility, including calendar availability and timeline blackouts through \(A_{m,s}\) (E1):
Role compatibility, machines can only work roles allowed by the block’s assigned harvest system (E2):
Production upper bound per assignment (E3):
Block window enforcement (E4):
Role-production aggregation (E5):
Machine positions and moves (E6; each machine’s position is a unit flow through one layer per slot; idle slots keep the position):
A position changes only into a worked block, and working block \(b\) puts the whole unit of flow at \(b\). For binary \(x\), every path of a decomposition of the flow therefore follows the machine’s true position sequence, so \(y_{m,b',b,s}=1\) exactly when machine \(m\) works \(b\) in slot \(s\) and its last worked block before \(s\) (or \(b^{0}_m\)) is \(b'\ne b\), also when idle slots lie in between; all other \(y\) are 0. The move term of OBJ is thus exact and equals the mobilization and transition accounting of the heuristics and of the playback KPIs. The implementation builds the network only for machines that can incur a positive move cost and only for the slots in which the machine can work (other slots keep its position), starting arcs only at positions reachable before the slot; when all move costs \(c_{m,b',b}\) of a machine are equal, the arcs \(y_{m,b',b,s}\) are replaced by arcs to and from one hub node per slot, an equivalent network with \(O(|\mathcal{B}|)\) instead of \(O(|\mathcal{B}|^2)\) arcs.
Staged inventory start and balance per upstream role (E7; each downstream role consumes its own output from the staged output of every upstream role, so a role with several upstream roles can only process what each of them has staged):
Staged output is therefore available downstream from the next shift slot. For a linear chain (\(|\mathcal{U}_{r,b}|=|\mathcal{N}_{u,b}|=1\)) these are the FHOPS 1.0.0 inventory equations of the downstream role. The downstream roles of a fork (\(|\mathcal{N}_{u,b}|>1\)) split the staged output of \(u\): each unit is consumed by one of them. A join (\(|\mathcal{U}_{r,b}|>1\)) consumes each unit of its output from the pool of every upstream role. Hence, without carried-in staged volume, a fork that joins again (a diamond \(u\to\{r_1,r_2\}\to t\)) delivers at most half of the output of \(u\), i.e. at most \(W_b/2\).
Activation, production gating (E8a):
An assigned unlocked machine activates its role; a machine locked to the block may stay idle (\(x=1\), \(p=0\)) without activating it, so a lock never forces production that the staged volumes cannot support.
Head-start buffer (E8b; with \(I_{u,b,\operatorname{prev}(s_1)} := \bar{I}_{u,b}\)):
Head-start waiver (E8c; the buffer is waived once every upstream role has output its carried-in remaining volume, so blocks smaller than a buffer can still be finished):
Loader truckload threshold (E9): a producing loader needs one truckload, or the whole remaining block volume when it is smaller, staged by every upstream role at the start of the slot,
linearized exactly as follows (all coefficients are data; \(W_b - D_{b,\operatorname{prev}(s)} \in [0, W_b]\)):
\(\lambda_{b,s}=1\) is only possible once at most one truckload remains; it then relaxes the threshold to the remaining volume. Outside \(\mathcal{S}^{\text{tail}}_b\) the remaining volume provably exceeds a truckload, so no binary is needed there.
Block completion balance with leftover slack (E10):
Landing capacity per shift slot, the machines working a landing’s blocks concurrently (E11):
where \(N^{\text{lock}}_{\ell,s} = \sum_{b:\,\ell(b)=\ell}|\mathcal{K}_{b,s}|\) is the number of machines locked to the landing’s blocks in slot \(s\). With \(\omega^{\text{land}}=0\) the capacity is hard; a slot in which locks alone exceed it keeps the locked machines, admits no other machine, and is reported as a warning (the heuristics charge the same unavoidable overload). With \(\omega^{\text{land}}>0\) the marginal price \(k\,\omega^{\text{land}}\) of the slack pieces increases, so an optimal solution uses \(S_{\ell,s,1},\dots,S_{\ell,s,e}\) for \(e\) machines beyond capacity and pays \(\omega^{\text{land}}\,e(e+1)/2\): the \(k\)-th machine beyond capacity in a slot costs \(k\,\omega^{\text{land}}\), as in the heuristics’ evaluation.
Remaining role output, no role can handle more wood than the block still holds (E12):
Locked assignments (E13; a lock without a shift label pins every available shift of its day; a lock with a shift label pins only that slot):
where \(\chi_k=0\) when the lock contradicts the model (day outside the block window, or a machine whose role is not in the block’s harvest system) and \(\chi_k=1\) otherwise. Such locks are rejected by scenario validation; a lock that still reaches the model pins the machine to idle and is reported as a warning instead of making the model infeasible. A second lock on an already locked machine slot is ignored with a warning.
Domain restrictions (D1):
Initial state (INIT). The optional Scenario.initial_state supplies \(\bar{I}_{u,b}\) (E7, E8b), \(R^{0}_{r,b}\) (E8c, E12), and
\(b^{0}_m\) with \(\mathcal{M}^{0}\) (E6); the rolling-horizon driver uses it to carry state from one planning window to the next. Without
Scenario.initial_state and Scenario.locked_assignments (\(\bar{I}\equiv 0\); \(R^{0}_{r,b}= R_{r,b}= W_b\);
\(\mathcal{M}^{0}=\mathcal{K}=\emptyset\)) the initial-state and lock terms vanish.
Changes from FHOPS v1.0.0 (1.0.1). The following corrections make every MILP plan physically feasible and replayable by the playback sequencing tracker without violations: (i) (E12) the remaining-output cap applies to every role with \(R_{r,b}=\min(R^{0}_{r,b},W_b)\) (\(W_b\) by default), complemented by the per-slot cap \(z_{r,b,s}+D_{b,s}\le W_b\) where the flow balances do not imply it (v1.0.0 let upstream roles output more volume than the block holds and used that volume to meet head-start and loader thresholds); (ii) (E8c) the head-start constraint is waived through \(h_{r,b,s}\) once every upstream role has output its carried-in remaining volume, so blocks smaller than a buffer can still be finished; (iii) (E9) the loader threshold is \(\min(q^{\text{batch}}_b, W_b - D_{b,\operatorname{prev}(s)})\), the volume still to deliver at the start of the slot (v1.0.0 always required a full truckload), so the last partial truckload of a block can be loaded; (iv) (E7) staged inventories are kept per upstream role, \(I_{u,b,s}\) (v1.0.0 kept one inventory per downstream role fed by the sum of its upstream roles’ outputs, so a role with several upstream roles could process wood that only one of them had handled); (v) (E8a, E13) the activation binary \(g_{r,b,s}\) gates production only and machines locked to a block may stay idle, so locks (new in 1.0.1) never make the model infeasible; contradictory locks are pinned to idle and reported; (vi) (E2) blocks without a harvest system (\(\mathcal{B}\setminus\mathcal{B}^{\text{seq}}\)) have no role obligations, as documented in the data contract (v1.0.0 applied the registry’s default system to them); (vii) (E1) timeline blackouts, which v1.0.0 enforced only in the heuristics, set \(A_{m,s}=0\) in every slot of a blackout day for every machine; (viii) (E11) landing capacity counts the machines on a landing per shift slot and is hard when \(\omega^{\text{land}}=0\), as in the heuristics (v1.0.0 counted machine-shifts per day, \(\sum_{\sigma} x_{m,b,(d,\sigma)} \le C_{\ell} + S_{\ell,d}\), with a slack that was free at the default \(\omega^{\text{land}}=0\), so the capacity did not bind; on single-shift scenarios the per-slot and per-day counts coincide); (ix) (E6) a move is charged when a machine works a block other than its position, its last worked block or \(b^{0}_m\), through the position network above: staying on a block costs nothing (v1.0.0 also charged \(\omega^{\text{trans}}\) for consecutive slots on the same block), and a move across idle slots, or from \(b^{0}_m\) into a first worked slot after \(s_1\), is charged (v1.0.0 linked only consecutive slots through binaries \(y_{m,b',b,s}\ge x_{m,b',\operatorname{prev}(s)}+x_{m,b,s}-1\), so idling one slot avoided the move cost that the heuristics and the KPIs charge); (x) (old E9) the loader batching variables of v1.0.0 (\(z_{r,b,s}=q^{\text{batch}}_{b}n_{r,b,s}+u_{r,b,s}\) with \(n_{r,b,s}\in\mathbb{Z}_{\ge0}\), \(0\le u_{r,b,s}\le q^{\text{batch}}_{b}\)) were removed because every \(z_{r,b,s}\ge 0\) satisfies them; the truckload rule is the loader threshold above. Playback, the heuristics, and the rolling-horizon carry-forward apply the same rules: staged output is available from the next shift slot, buffers and truckload thresholds are staged volume at the start of the slot, and production is capped by \(R_{r,b}\) and by the volume the block still holds. For linear harvest systems without loaders, without locks, and with no (or a consistent) initial state, (iii)–(v) leave the equations unchanged apart from indexing the staged inventory by the upstream instead of the downstream role. The initial state (INIT), locked assignments (E13), and the earliness stage (OBJ2) are new in 1.0.1 and vanish by default.
Implementation mapping (equation blocks to code).
Primary modules are operational.py, data.py, and driver.py under src/fhops/model/milp. Regression tests live under tests/model (builder,
moves, landing capacity, blackouts, earliness, driver) and cover the MILP builder plus driver integration.
Block |
Pyomo objects and rules |
|---|---|
OBJ |
|
OBJ2 |
|
INIT |
|
E1 |
|
E2 |
|
E3 |
|
E4 |
|
E5 |
|
E6 |
|
E7 |
|
E8a |
|
E8b |
|
E8c |
|
E9 |
|
E10 |
|
E11 |
|
E12 |
|
E13 |
|
D1 |
Domains of |
This formulation is the canonical mathematical reference for FHOPS operational MILP documentation and thesis-level reporting.