Log Hauling

FHOPS can carry an optional log-hauling layer on top of the operational scenario. The layer adds the entities that the harvesting and tactical-operational contracts do not carry: log sorts, contractors, truck types and trucks, depots, sort yards, haul arcs, loader location bindings, and a driver policy.

The model follows the two-phase decomposition in Ghotb (2023):

  1. Phase I decides daily sorted and unsorted log flows from blocks and landings to sort yards, yard processing, inventory, and contractor workload.

  2. Phase II assigns the resulting truckloads to compatible trucks and contractors and schedules trucks and loaders with continuous time, synchronization, and overtime.

  3. Phase III solves the large daily scheduling instances with simulated annealing and an SPT schedule decoder.

P11.1 ships the contract and validation. P11.2 adds the Phase I flow model and goal programming. P11.3 adds the Phase II continuous-time scheduling model and its simulator/validator. P11.4 adds the simulated-annealing solver for larger days. P11.5 adds the fhops haul CLI, the operational/ tactical bridges, and the stable run artefacts. P11.7 adds the explicit marine/barge path (TransportMode, barges, ports, water arcs, and port-to-port cycles). The Phase I/II/III models remain available from Python.

Schema version

The hauling extension uses schema_version: "1.1.0". The loader still accepts 1.0.0 scenarios, which have no hauling section. A scenario that contains hauling: with schema_version: 1.0.0 is refused: the version is the guard that tells an older reader the file has a section it does not understand.

Minimal example

The complete example is in examples/hauling/. The core bundle is a normal FHOPS scenario; the hauling: section is additive.

name: Hauling demo
num_days: 20
schema_version: 1.1.0
data:
  blocks: blocks.csv
  machines: machines.csv
  landings: landings.csv
  calendar: calendar.csv
  prod_rates: production_rates.csv
hauling:
  planning_horizon_days: 5
  start_day: 1
  log_sorts:
    - {id: fir_saw, product_id: fir_saw, sorted: true}
    - {id: mixed_unsorted, sorted: false}
  contractors:
    - {id: c1, preferred_daily_truckloads: 8}
  truck_types:
    - id: highway
      capacity_m3: 45
      fixed_cost: 120.0
      variable_cost_per_minute: 1.2
      overtime_cost_per_minute: 1.8
      can_carry_sorted: true
      can_carry_unsorted: true
  trucks:
    - {id: c1_h1, contractor_id: c1, truck_type_id: highway}
  depots:
    - {id: depot}
  sort_yards:
    - id: y1
      unsorted_storage_m3: 5000
      sorted_processing_m3_per_day: 1500
      unsorted_processing_m3_per_day: 800
      total_processing_m3_per_day: 2000
  loader_locations:
    - {loader_id: ldr_b1, location_id: b1, location_kind: pickup}
    - {loader_id: ldr_y1, location_id: y1, location_kind: delivery}
  supply:
    - {block_id: b1, log_sort_id: fir_saw, sorted_m3: 800, unsorted_m3: 0}
  demand:
    - {yard_id: y1, log_sort_id: fir_saw, day: 1, volume_m3: 160}
  haul_arcs:
    - {block_id: b1, yard_id: y1, truck_type_id: highway, travel_minutes: 150}
  depot_legs:
    - {depot_id: depot, location_id: b1, truck_type_id: highway, travel_minutes: 35}
    - {depot_id: depot, location_id: y1, truck_type_id: highway, travel_minutes: 30}
  contractor_blocks:
    - {contractor_id: c1, block_id: b1}
  driver_policy:
    regular_minutes: 510
    max_overtime_minutes: 150
    overtime_violation_penalty_per_minute: 10

The section may also live in a separate YAML file referenced from data.hauling:

data:
  hauling: hauling.yaml

Marine and barge legs

A barge is not a truck type. The contract gives marine transport its own equipment and topology so the model can report water volume, barge trips, terminal handling, and port synchronization instead of silently applying truck capacity and a road cycle.

The marine path is a four-part chain:

  1. a road pickup leg from a block to an origin Port;

  2. terminal handling at the origin port;

  3. a water leg on a WaterArc operated by a Barge of a BargeType;

  4. terminal handling at the destination port and a road delivery leg to the sort yard.

A MarineRoute binds those parts to a block, yard, contractor, drayage truck type, port pair, and water arc. Phase I treats the marine route as an alternative supply path with separate pickup, water, and delivery volumes. Phase II schedules the road drayage loads in the truck/loader MIP and schedules the barge cycles with fhops.hauling.marine. The barge scheduler assigns port loaders, loads, sails, unloads, returns empty, and reports the synchronized pickup and delivery truck windows.

The redistributable fixture is examples/hauling_marine/. It represents a synthetic Sunshine Coast pickup, a Sechelt terminal, a water leg to Howe Sound, and a Howe Sound terminal feeding a sort yard:

hauling:
  log_sorts:
    - {id: fir_saw, sorted: true}
  truck_types:
    - {id: highway, capacity_m3: 45, variable_cost_per_minute: 1.2}
  trucks:
    - {id: c1_h1, contractor_id: c1, truck_type_id: highway}
  barge_types:
    - {id: log_barge, capacity_m3: 3000, load_minutes: 30, unload_minutes: 30}
  barges:
    - {id: c1_b1, contractor_id: c1, barge_type_id: log_barge}
  ports:
    - {id: sechelt, storage_m3: 500, handling_minutes_per_trip: 20,
       handling_cost_per_trip: 100, daily_handling_m3: 1000}
    - {id: howe_sound, storage_m3: 500, handling_minutes_per_trip: 25,
       handling_cost_per_trip: 120, daily_handling_m3: 1000}
  water_arcs:
    - {id: sc_hs, origin_port_id: sechelt, destination_port_id: howe_sound,
       barge_type_id: log_barge, travel_minutes: 200, distance_km: 80,
       return_travel_minutes: 200, load_minutes: 30, unload_minutes: 30}
  port_legs:
    - {port_id: sechelt, location_id: b_sc, side: origin,
       truck_type_id: highway, travel_minutes: 35, return_travel_minutes: 35}
    - {port_id: howe_sound, location_id: y1, side: destination,
       truck_type_id: highway, travel_minutes: 30, return_travel_minutes: 30}
  marine_routes:
    - {id: sc_hs_y1, block_id: b_sc, yard_id: y1, origin_port_id: sechelt,
       destination_port_id: howe_sound, water_arc_id: sc_hs,
       contractor_id: c1, truck_type_id: highway}
  loader_locations:
    - {loader_id: loader_sechelt, location_id: sechelt, location_kind: port}
    - {loader_id: loader_howe_sound, location_id: howe_sound, location_kind: port}

Run the fixture with:

fhops haul validate examples/hauling_marine/scenario.yaml
fhops haul plan examples/hauling_marine/scenario.yaml --out-dir runs/marine-plan
fhops haul schedule examples/hauling_marine/scenario.yaml --day 1 \
    --flow-dir runs/marine-plan --out-dir runs/marine-day1
fhops haul report runs/marine-day1

The contract rejects a barge-named TruckType (barge, vessel, ferry, marine, or ship) with a message that names the marine entities to declare instead. A HaulArc accepts only mode: road; water legs must use a WaterArc and MarineRoute. If any marine entity is present but no MarineRoute connects it to a block and yard, validation fails rather than silently ignoring the marine data.

Marine assumptions and limits:

  • Phase I uses the barge capacity for the water leg, the truck capacity for the drayage legs, and charges barge variable and per-trip terminal handling separately from drayage cost. The barge scheduler charges the barge type’s fixed dispatch cost once per used barge.

  • Port inventory carries over between days; storage_m3 caps it (zero means no storage constraint) and daily_handling_m3 caps the handled water volume (None means uncapped).

  • A barge voyage can span multiple days. arrival_lag_days is the whole-day lag from departure to cargo arrival; occupancy_days is the whole-day round-trip occupancy. The destination port and yard receive cargo on the arrival day, and the barge fleet uses a sliding occupancy window rather than a one-day budget.

  • A departure that cannot complete its occupancy window inside the horizon is fixed to zero. Demand before arrival is infeasible unless destination inventory already covers it.

  • Phase II schedules the road drayage loads and the barge cycles separately. The returned pickup_truck_depart/delivery_truck_arrive columns are the synchronization handoff; the first release reports them rather than hard-linking the two MIPs.

  • A truck cannot be at both the origin and destination port in one road movement. A missing cross-stage empty leg is represented by a full-day deadhead so an unsupported transfer is infeasible rather than invented as a short road trip.

  • Tide, weather, naval architecture, customs, and a full coastal network remain out of scope.

Validate a scenario with the normal command:

fhops validate examples/hauling/scenario.yaml

The output reports the hauling dimensions in addition to the core scenario counts.

Entity rules

The contract validates identifiers, units, ranges, and cross references. The important rules are:

  • Every block_id in supply, haul arcs, contractor eligibility, and compatibility must exist in the scenario’s blocks.

  • Every loader_id must be a machine in the scenario. A pickup loader points at a block, a delivery loader points at a sort yard, and a port loader points at a port.

  • Every truck references a known contractor and truck type. A truck type must be able to carry sorted or unsorted logs (or both). Barge-named truck types are rejected.

  • A sort yard’s total daily processing capacity must be at least its sorted and unsorted component capacities.

  • Demand days are one-indexed relative to the hauling horizon, not the parent scenario. The hauling horizon must fit inside the parent num_days.

  • Supply, demand, haul arcs, and contractor-block pairs are unique. Marine routes are unique by block, yard, truck type, and barge type.

  • A hauling section needs at least one truck, one depot, and one haul arc or one marine route.

  • A marine route needs an origin and destination port, a matching water arc, an origin and destination port leg, an enabled barge of the water arc’s type, and an enabled truck of the drayage type.

Contractor eligibility

contractor_blocks is optional. When the list is empty, every contractor may serve every block. When it contains entries, it is an allowlist: only the listed contractor/block pairs are eligible. This mirrors Ghotb’s alpha_bo parameter.

Truck-type compatibility comes from the truck type’s can_carry_sorted and can_carry_unsorted flags and from truck ownership. A contractor can only dispatch trucks it owns. The optional compatibility list is reserved for later exceptions and is not required for the first release.

Units and defaults

  • Volumes: cubic metres (m³).

  • Travel and service times: minutes.

  • Costs: scenario currency; fixed cost is per dispatched truck per day, variable and overtime costs are per minute.

  • planning_horizon_days defaults to 20 (four weeks); start_day defaults to 1.

  • driver_policy.regular_minutes defaults to 510 (8.5 h); max_overtime_minutes defaults to 150 (2.5 h). These match the thesis case.

Phase I solve

The Phase I model is a Python API in fhops.hauling.flow:

from fhops.scenario.io import load_scenario
from fhops.hauling.flow import build_flow_problem, solve_flow, summarize_flow_result

scenario = load_scenario("examples/hauling/scenario.yaml")
problem = build_flow_problem(scenario)
cost = solve_flow(problem)                      # Ghotb's Z1
balanced = solve_flow(problem, objective="goal", weights=(1.0, 0.5))
print(summarize_flow_result(balanced))
print(balanced.flows.head())
print(balanced.contractor_workload.head())

solve_flow accepts objective="cost", "workload", or "goal". A goal solve with no explicit goals runs a cost-only solve first, uses its objective as Goal1, sets Goal2 to zero, and derives each contractor’s preferred daily truckloads from that cost plan unless the contract or the caller supplies them. The normalized goal objective is

w1 * dev_cost / max(|Goal1|, 1) + w2 * dev_workload / max(sum(phi), 1).

The result payload carries:

  • flows: block/yard/sort/contractor/type/day volumes;

  • truckloads: sorted and unsorted load counts per block/yard/contractor/type/day;

  • yard_inventory: unsorted and water inventory, processed volume, and demand;

  • block_inventory: sorted and unsorted roadside inventory at the horizon end;

  • contractor_workload: daily truckloads, preferred level, and deviation;

  • marine_flows and barge_trips: water-leg volume, barge trips, terminal handling, departure and arrival days, transit/occupancy days, and leg cost/time provenance;

  • port_inventory: opening, inbound, outbound, ending inventory, and handled volume per port/sort;

  • marine_truckloads: pickup and delivery drayage load counts for Phase II;

  • objective values, goal deviations, solver provenance, and diagnostics.

Units and assumptions:

  • cycle_minutes = 2 * HaulArc.travel_minutes + pickup service + delivery service.

  • Transport cost per started truckload is TruckType.variable_cost_per_minute * cycle_minutes. The fixed truck cost belongs to Phase II, which decides whether to dispatch a truck or pay overtime.

  • The fleet-capacity constraint uses DriverPolicy.regular_minutes.

  • A zero roadside_inventory_penalty_per_m3 lets the model leave supply at the block for free. Set a positive penalty for a meaningful flow plan.

  • Compatibility is enforced by omitting infeasible variables, not by a big-M.

  • The workload deviation is linearised with two inequalities; goal deviations are non-negative continuous variables.

Phase II schedule

The Phase II model turns the Phase I integer truckloads into a single-day truck and loader schedule:

from fhops.hauling import (
    build_schedule_problem,
    solve_schedule,
    simulate_plan,
    validate_schedule,
)

flow_problem = build_flow_problem(scenario)
flow_result = solve_flow(flow_problem)
schedule_problem = build_schedule_problem(scenario, flow_result, day=1)
result = solve_schedule(schedule_problem, time_limit=60)
print(result.schedule.head())
print(result.truck_summary.head())
print(result.loader_summary.head())
print(validate_schedule(schedule_problem, result.schedule))

The model assigns every truckload to exactly one compatible truck, routes each truck from the central depot through its loads and back, assigns each pickup/delivery load to a loader at its location, and orders the loads at each loader. Time is continuous. Regular minutes and overtime are separate variables, so the objective charges:

sum fixed_cost * used_truck + sum variable_cost * regular_minutes + sum overtime_cost * overtime_minutes.

The result contains:

  • schedule: one row per load with the truck, contractor, type, pickup/delivery loaders, arrival, service start/finish, waiting, route order, and truck return/regular/overtime minutes;

  • truck_summary: used trucks, load counts, return time, regular/overtime minutes, and cost components;

  • loader_summary: loader kind/location, load count, busy minutes, first start, and last finish;

  • barge_schedule: one row per barge trip with the barge, port loaders, load/sail/unload/return times, waiting, synchronized pickup/delivery truck windows, and cost components;

  • barge_summary: trips, volume, sailing/load/unload/return minutes, and barge/handling cost;

  • plan: the truck routes and loader orders.

simulate_plan recomputes the earliest schedule for the result’s route and loader order by fixed-point iteration, without using the MIP’s time variables. validate_schedule checks compatibility, synchronisation, loader overlap, route travel, and return-time consistency. Use both as independent oracles.

The exact MIP is intended for small and medium daily instances. The real case in the thesis has 81 truckloads and 168 nodes; Phase III (P11.4) handles that scale with simulated annealing.

Phase III simulated annealing

The exact scheduling MIP is the oracle for small days. For large days, use the SA solver:

from fhops.hauling import SAConfig, solve_sa, summarize_sa_result

result = solve_sa(
    schedule_problem,
    SAConfig(
        initial_temperature=300,
        min_temperature_ratio=0.0005,
        cooling_rate=0.95,
        iterations_per_temperature=90,
        max_iterations=50_000,
        seed=42,
    ),
)
print(summarize_sa_result(result))
print(result.schedule.head())

The solution is a permutation of truck routes. The decoder assigns each load to the fastest loader at its location, derives pickup and delivery loader orders from projected arrival times (SPT), and calls the same simulate_plan() used by the Phase II oracle. The neighbourhood operators are swap, reversion, and insertion; insertion may move a load to another compatible truck. The acceptance rule is the usual Metropolis rule with geometric cooling.

SAConfig defaults come from the thesis Taguchi calibration: T0=300, T_min = 0.0005 * T0, alpha=0.95, and 90 moves per temperature. They are configurable. The result carries the best schedule, truck/loader summaries, route plan, objective components, iterations, accepted moves, runtime, seed, and a per-move history. An overtime violation above regular_minutes + max_overtime_minutes is soft-penalized in the SA cost; the exact MIP treats it as hard.

For a small day, the SA solver should match the MIP objective. The example day-1 case currently reaches the same 2992.50 objective with both methods. For a parameter sweep, run:

python scripts/benchmark_hauling.py --solver both --day 1
python scripts/benchmark_hauling.py --solver sa --calibrate --sa-iters 500      --out-dir tmp/hauling-calibration

CLI

The fhops haul commands are:

fhops haul validate examples/hauling/scenario.yaml
fhops haul plan examples/hauling/scenario.yaml --out-dir runs/haul-plan
fhops haul schedule examples/hauling/scenario.yaml --out-dir runs/haul-day1      --flow-dir runs/haul-plan --day 1 --solver sa --sa-iters 5000
fhops haul solve examples/hauling/scenario.yaml --out-dir runs/haul-solve      --day 1 --solver mip --time-limit 60
fhops haul report runs/haul-solve

plan solves Phase I and writes the flow artefacts. schedule solves Phase II/III for one day; it reads an existing Phase I run with --flow-dir or solves Phase I in memory. solve runs both and writes a merged summary. report renders a Markdown report from any run directory.

Stable artefacts

The writers in fhops.hauling.reporting use these names:

Phase I:

  • haul_flows.csv

  • haul_truckloads.csv

  • haul_yard_inventory.csv

  • haul_block_inventory.csv

  • haul_contractor_workload.csv

  • haul_marine_flows.csv

  • haul_marine_truckloads.csv

  • haul_barge_trips.csv

  • haul_port_inventory.csv

  • haul_kpis.json

  • haul_summary.json (output schema 1.1.0; marine rows carry departure/arrival and transit/occupancy fields)

Phase II/III:

  • haul_schedule.csv

  • haul_truck_summary.csv

  • haul_loader_summary.csv

  • haul_barge_schedule.csv

  • haul_barge_summary.csv

  • haul_kpis.json

  • haul_summary.json

The marine files are present but empty for road-only scenarios.

A combined solve writes both families and a merged haul_summary.json with phase I+II/III.

Operational and tactical handoff

The operational model does not carry product yields, and the tactical model uses a different contract. The bridge is explicit:

from fhops.hauling import (
    scenario_from_operational_plan,
    scenario_from_tactical_plan,
)

# Operational assignments -> block/sort supply. Without sort_shares, equal shares are used
# and a warning is recorded.
bridged = scenario_from_operational_plan(
    scenario,
    assignments,
    sort_shares={"fir_saw": 0.5, "hemlock_pulp": 0.3, "mixed_unsorted": 0.2},
)

# Tactical production + facility demand -> hauling supply/demand.
bridged = scenario_from_tactical_plan(
    scenario,
    tactical,
    period_id="p1",
    production=tactical_result["production"],
)

The tactical bridge maps products to log sorts through LogSort.product_id (or a matching LogSort.id), facilities to yards through SortYard.facility_id, and spreads period demand evenly over the hauling horizon. It records a warning when a product/facility has no link, when no production table is supplied, or when the even split is used. It does not invent a missing haul arc; the base scenario’s arcs remain the travel-time source.

residual_report returns supply, demand, and the residual per log sort before a solve. A positive residual means demand exceeds supply.

Scale case

examples/hauling_scale/ is a generated stress case: 13 blocks, 3 yards, 4 contractors, 60 trucks, 3 log sorts, block polygons, and about 90 truckloads/day. Regenerate it with:

python scripts/generate_hauling_scale_case.py

It is deliberately larger than the CI slice in examples/hauling/. The Phase I model may need a time limit and return a feasible incumbent rather than a proven optimum; the SA scheduler is the intended Phase II/III path at this size. The case allows 300 overtime minutes so a full day remains feasible without turning the exercise into a fleet-sizing study.

Limitations

  • The first cut is deterministic. Emissions, electric-truck charging, stochastic supply/demand, loader breakdowns, lunch breaks, driver changes, and contractor collaboration are out of scope.

  • Phase II requires exactly one depot and a depot leg for every used block/yard and truck type. A missing leg is a named error, never an invented travel time.

  • Phase II uses the same loaded travel time for the empty yard-to-block leg unless HaulArc.return_travel_minutes is supplied.

  • The exact schedule MIP is for small/medium days. The SA solver is the large-case path.

  • The SA decoder assigns each load to the fastest loader at its location. Multiple-loader balancing is left to the exact MIP; a future SA variant can add loader assignment moves.

  • Marine drayage and barge cycles are scheduled by separate solvers. The barge scheduler reports the synchronized road windows, but the first release does not add them as hard time windows in the truck MIP. Tide, weather, naval architecture, customs, and a full coastal network are out of scope.

  • A truck may not transfer from a destination-port delivery leg to an origin-block pickup leg without a modelled deadhead; a missing cross-stage leg is treated as a full-day deadhead so the unsupported transfer cannot appear as a short invented road trip.

  • The operational bridge needs a sort split; equal shares are a visible default, not a real yield model. Supply a sort_shares mapping when the product split matters.

  • The tactical bridge needs a tactical production table for supply and spreads period demand evenly across the hauling horizon. It does not create missing haul arcs.

  • The confidential company case in the thesis is not redistributable. The repository example is synthetic; exact reproduction of the case’s dollar values is not a project target.

See also