I find declarative programming fascinating: we just model the problem, describe the result we want, and an โoracleโ (a.k.a. solver) conjures a solution for us. A lot of smart people spent decades building solvers for different problem classes, and I wanted an excuse to go and play with some of these solvers to learn optimization tricks along the way.
In this post, I describe what I learned solving a toy flow optimization problem with MIP and SAT solvers. I picked belt balancing from the Factorio video game. It looks innocuous at first, but it's in reality NP complexity. It's possible to solve by hand small versions, allowing us to check correctness, but it quickly becomes impossible to humanly solve even for medium size balancers that are very useful in the game. What the majority of players do is use pre-built online solutions. The game incentivizes sharing designs, called blueprints, as a string, and there's a thriving community that comes up with clever solutions.
Let's define the problem next.
The Throughput-Unlimited Factorio Belt Balancer
A core dynamic in Factorio is transporting items between different production buildings using conveyor belts, e.g., transporting raw stone from a mining drill to a Stone Furnace to be transformed into a brick. Since the throughput of a belt lane is limited, it's necessary to have multiple parallel lanes. To help prevent bottlenecks in the factory, parallel lanes need to be balanced, for example, a slow miner from slowing down producing steel plates.
The last Furnace is sometimes idle waiting for new resources. We don't want that.To perfectly balance two belt lanes, we can use a Mixer component: that's a 2x2 Belt Balancer. There's an issue that arises with more than two lanes: the output flow may be lower than the input, slowing down all the lanes. We want two properties: all the inputs need to be connected to all the outputs, and the total maximum flow in input needs to be maintained in output. If only the former problem is solved, we have a Throughput-Limited balancer, which is easier to build, takes up less space, but unfortunately slows down all the lanes. The Throughput-Unlimited Belt Balancer instead ensures that every input flow is evenly distributed across all the outputs.
Example of 4 x 4 naรฏve Throughput-Limited belt balancer. This is not what we want.Given the exponential complexity, we cannot expect to find arbitrarily large solutions, but my approach works well in practice since rarely players use larger than 16x16 balancers. For this project, I focused on finding a reasonably small feasible balancers, prioritizing small 2D area occupied, without necessarily finding the optimal number of components.
4 x 4 Throughput-Unlimited belt balancer. This is what we want.As a first implementation, we model the problem as Mixed Integer Programming (MIP).
Continuous flows: Mixed Integer Programming
This first model is Mixed Integer Programming, we use the SCIP solver and Google OR-tools framework. Input flows are modeled as separate float variables. Every input flow should be present in every output in equal measure. I created a binary variable array for every component and every direction on every 2D cell in the grid. We have three types of components: belts, underground belts, and mixers. Belts take up one cell, mixers take up two cells, and underground belts take up two cells. Underground belts are set apart up to a maximum distance, modeled as an extra dimension on the array e.g. entrance 3 cells away is u[x][y][d][3]
where x, y are the coordinates of the cell that contains the entrance, d is the direction and 3 is the distance of the exit in direction d
from the entrance.
1# flow of a source in a direction
2f = [[[[solver.NumVar(-1, 1, f'f_{i}_{j}_{s}_{d}') for d in DIRECTIONS] for s in range(num_sources)] for j in range(H)] for i in range(W)]
3
4# belt in a direction
5b = [[[solver.BoolVar(f'b_{i}_{j}_{d}') for d in DIRECTIONS] for j in range(H)] for i in range(W)]
6
7# mixer in a direction. note that i, j are the left cell of the mixer
8m = [[[solver.BoolVar(f'm_{i}_{j}_{d}') for d in DIRECTIONS] for j in range(H)] for i in range(W)]
9
10# underground belt in a direction
11u = [[[[solver.BoolVar(f'u_{i}_{j}_{d}_{n}') for n in range(MAX_UNDERGROUND_DISTANCE)] for d in DIRECTIONS] for j in range(H)] for i in range(W)]
12
After modeling all the rules as constraints, this first implementation solves immediately small grids, for example, it can compute a 2x2 balancer and transport multiple parallel flows to the other side of the grid, both trivial problems, but it gives us confidence that the constraints are implemented correctly. The main problem of this approach is numeric instability: since the flows are modeled as floating point variables and there are a lot of equality constraints, on a 3x3 balancer the numeric errors accumulate and make it very hard to converge on a solution.
Since this model doesn't even scale to a 3x3 balancer, I needed to explore alternative approaches. Before migrating to a different solver, let's build tests and tooling to inspect the solution.
How to test the solution
Firstly, we need to represent the output in the console to quickly see if it's correct. We represent every component with a Unicode character: โฒ
for belt, โฟโพ
for mixer, and โทโงโงโงโฆ
for underground belt at distance 3.
โฅโฟโพโฅ
โงโฅโฅโณ
โณโถโถโฒ
โฟโพโงโง
โฅโฒโโ
โณโณโณโฒ
โฟโพโฟโพ
Secondly, we build tests around very simple cases:
transport a single flow on the opposite side of the grid
mix two flows with a single mixer
use underground belts to minimize the number of components used.
Maintaining tests gives us confidence that every change we'll make won't break previous solutions.
It proved to be very valuable to also represent flows entering and exiting every cell according to the rule of the components. Many times I modeled the constraints slightly incorrectly and the solver cleverly exploited bugs in my model to quickly reach a simple but incorrect solution.
flows:
โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-4 โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โผ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 4 โงโงโงโงโงโง โงโงโงโงโงโง|
โงโงโงโงโงโง โผ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 4 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โผ(0) 4 โถ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 4 โถ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 4 โถ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 4 โถ(3)-4 โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โถ(0)-4 โ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โถ(1)-4 โ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โถ(2)-4 โ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โถ(3)-4 โ(3) 4| โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โ(0) 4 โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โ(1) 4 โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โ(2) 4 โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โ(3) 4|
โฒ(0)-4 โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โฒ(0)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โงโงโงโงโงโง โถ(2) 8 โงโงโงโงโงโง โฒ(3)-8 โงโงโงโงโงโง โถ(3) 8 โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โถ(2) 8 โ(2)-8 โงโงโงโงโงโง โงโงโงโงโงโง โถ(3) 8 โ(3)-8| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 8 โงโงโงโงโงโง โ(2)-8 โงโงโงโงโงโง โผ(3) 8 โงโงโงโงโงโง โ(3)-8|
โงโงโงโงโงโง โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง|
โฒ(0)-8 โผ(0)16 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โผ(1)16 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โผ(2)16 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โผ(3)16 โงโงโงโงโงโง โงโงโงโงโงโง|
underground flows:
โงโงโงโงโงโง โผ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 4 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โผ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 4 โงโงโงโงโงโง โงโงโงโงโงโง|
โฒ(0)-4 โผ(0) 4 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โผ(1) 4 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โผ(2) 4 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โผ(3) 4 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โฒ(0)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-4 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โงโงโงโงโงโง โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โผ(0) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โผ(1) 8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โผ(2) 8 โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โผ(3) 8 โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โฒ(0)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โฒ(0)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(1)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(2)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โฒ(3)-8 โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง| โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง โงโงโงโงโงโง|
Another very useful tool is testing the solution inside the game. Factorio conveniently accepts a base64 representation of the components on the 2D grid, so we can easily validate that the solution found works correctly.
Lastly, a powerful debugging technique I invented to spot broken constraints is providing a known solution and see what constraints it violates. If a constraint is violated, it needs to be fixed in order to make the solution pass without breaking the tests.
With all these new fancy tools, we can simplify the model by migrating to CP-SAT.
Discretize flows: CP-SAT
CP-SAT is a Satisfiability solver built at Google. It can't deal with floating variables, so we have to discretize the flows. It solves the numeric instability problem, but it raises a new issue: needing to know in advance how many times the input flows will be split before reaching the output. Usually, it's necessary to set the input flow to at least a multiple of 2^number_of_splits to ensure the split can always be represented by an integer.
3 x 3 belt balancerBesides changing the flow variables type, nothing else in the model needs to be changed and yields solutions up to 6x6 balancer in very little time. We find an 8x8 after 30min of computation, suggesting an excessive number of free binary variables in the model and a very large search space.
6 x 6 belt balancer8 x 8 belt balancerNext, we can represent underground belts as two independent components, rather than an extra dimension in the belt array. We can model the underground flow with a second flow grid, that interacts with the rest of the flow through underground belt components.
1# Underground belt in a direction
2# Entrance
3ua = [[solver.NewBoolVar(f'ua_{i}_{j}') for j in range(H)] for i in range(W)]
4# Exit
5ub = [[solver.NewBoolVar(f'ub_{i}_{j}') for j in range(H)] for i in range(W)]
6
7# Underground flow of a source
8uf = [[[[solver.NewIntVar(-max_flow, max_flow, f'uf_{i}_{j}_{s}_{d}') for d in DIRECTIONS] for s in range(num_sources)] for j in range(H)] for i in range(W)]
We can also provide hints to the solver that the underground flow is zero in most cases. I don't know exactly why it works, but a plausible explanation is that zeroing underground flow implies not using underground belts, and since they are very sparse in known solutions it converges faster.
Additionally, if we assume that the first and the last rows must have mixers and we add a couple of belt components at random, applying a technique called symmetry breaking, the solver finds an 8x8 solution in just 83s, a great improvement!
1solve_factorio_belt_balancer(
2 grid_size=(8, 10),
3 num_sources=8,
4 input_flows=[
5 # Inputs
6 (0, 0, 'S', 0, 8),
7 (1, 0, 'S', 1, 8),
8 (2, 0, 'S', 2, 8),
9 (3, 0, 'S', 3, 8),
10 (4, 0, 'S', 4, 8),
11 (5, 0, 'S', 5, 8),
12 (6, 0, 'S', 6, 8),
13 (7, 0, 'S', 7, 8),
14 # Outputs
15 (0, 9, 'N', 0, -1),
16 (0, 9, 'N', 1, -1),
17 (0, 9, 'N', 2, -1),
18 (0, 9, 'N', 3, -1),
19 (0, 9, 'N', 4, -1),
20 (0, 9, 'N', 5, -1),
21 (0, 9, 'N', 6, -1),
22 (0, 9, 'N', 7, -1),
23 ...
24 (7, 9, 'N', 0, -1),
25 (7, 9, 'N', 1, -1),
26 (7, 9, 'N', 2, -1),
27 (7, 9, 'N', 3, -1),
28 (7, 9, 'N', 4, -1),
29 (7, 9, 'N', 5, -1),
30 (7, 9, 'N', 6, -1),
31 (7, 9, 'N', 7, -1),
32 ],
33 solution=
34 'โฟโพโฟโพโฟโพโฟโพ' +
35 'โงโงโงโงโงโงโงโฒ' +
36 'โงโงโงโงโงโงโงโง' +
37 'โงโงโงโงโงโงโงโง' +
38 'โงโงโงโงโงโงโงโง' +
39 'โงโงโงโงโงโงโงโง' +
40 'โงโงโงโงโงโงโงโง' +
41 'โงโงโงโงโงโงโงโง' +
42 'โงโงโงโงโงโงโงโณ' +
43 'โฟโพโฟโพโฟโพโฟโพ'
44)
After spending a few hours changing the problem formulation, objective function, using hints, and solver strategies, I eventually hit a wall trying and I was never able to find a 16x16 balancer. The next trick in the book is decomposing into sub-problems that could be solved independently, let's see how.
Pre-computing flow between mixers with Banes Networks
Trying to model some balancers by hand, I noticed a common layer structure. In the first layer, two sources are mixed together, in the second, two mixed flows from the first layer are mixed with other two, etc. This suggests that I can abstract how flows are mixed and provide it to the solver as an additional constraint. Then the solver only needs to decide where to place a pre-computed number of mixers and how to connect them using belts. We can also lower the cardinality of the flows since we don't have to guarantee maximum throughput explicitly.
In order to force the output of a mixer to go as input of the next one, I made them different flows reducing to a graph coloring problem. Empirically, I observed that the number of different flows (edge colors in the graph) grows linearly with the number of inputs and outputs.
Banes network for an 8x8 balancerI found out that these abstract flow networks with two inputs and two outputs are called Banes networks, they are a special case of Clos Networks.
They are very easy to build by hand since they follow a regular structure, so we can skip designing a greedy algorithm to build them for now and simply hard-code them as input.
This change alone wasn't able to produce a solution for a 16x16 balancer because the program kept getting killed due to high memory consumption. Let's see next how to trade a lower memory footprint for a higher running time.
Reduce the number of binary variables
One last optimization we can make is lowering the number of binary decision variables to reduce the solution search space. We can consolidate the direction
of components: rather than having one more dimension in every component type array, we can have a separate array with the four dimensions.
1# Belt
2b = [[solver.NewBoolVar(f'b_{i}_{j}') for j in range(H)] for i in range(W)]
3
4# Mixer. note that i, j are the left cell of the mixer
5m = [[solver.NewBoolVar(f'm_{i}_{j}') for j in range(H)] for i in range(W)]
6
7# Underground belt in a direction
8# Entrance
9ua = [[solver.NewBoolVar(f'ua_{i}_{j}') for j in range(H)] for i in range(W)]
10# Exit
11ub = [[solver.NewBoolVar(f'ub_{i}_{j}') for j in range(H)] for i in range(W)]
12
13# Flow of a source
14f = [[[[solver.NewIntVar(-max_flow, max_flow, f'f_{i}_{j}_{s}_{d}') for d in DIRECTIONS] for s in range(num_sources)] for j in range(H)] for i in range(W)]
15
16# Underground flow of a source
17uf = [[[[solver.NewIntVar(-max_flow, max_flow, f'uf_{i}_{j}_{s}_{d}') for d in DIRECTIONS] for s in range(num_sources)] for j in range(H)] for i in range(W)]
18
19# Direction of the component
20dc = [[[solver.NewBoolVar(f'd_{i}_{j}_{d}') for d in DIRECTIONS] for j in range(H)] for i in range(W)]
The direction variable dc
can be combined with the other variables (e.g. the belts variable b
) to enforce the constraints only if they are both True
.
1# Flow Conservation for Belts
2# Flow into the belt must equal the flow out of the belt
3for i in range(W):
4 for j in range(H):
5 for s in range(num_sources):
6 for d in range(len(DIRECTIONS)):
7 solver.Add(
8 sum(
9 f[i][j][s][di]
10 for di in range(len(DIRECTIONS))
11 ) == 0
12 ).only_enforce_if([b[i][j], dc[i][j][d]])
The optimization reduces component decision variables by roughly 50%, drastically cutting the amount of memory needed. Here you can see 31K boolean variables before the optimization.
โฏ time python ft.py --solve_balancer=16x16_n
Initial satisfaction model '': (model_fingerprint: 0x71bae86a7b86068a)
#Variables: 60'032
- 31'360 Booleans in [0,1]
- 28'672 in [-1,1]
#kAtMostOne: 224 (#literals: 56'192)
#kLinear1: 3'643'136 (#enforced: 3'639'296 #multi: 14'336)
#kLinear2: 143'328 (#enforced: 116'576 #multi: 14'336)
#kLinearN: 98'336 (#enforced: 96'512) (#terms: 2'912'256)
After the optimization we're left with only 12K. We effectively traded fewer variables (less memory) for more constraints (more CPU time spent verifying every candidate solution).
โฏ time python ft.py --solve_balancer=16x16_n
Initial optimization model '': (model_fingerprint: 0xb681e6a3ac9e02fb)
#Variables: 45'056 (#bools: 1'024 in objective)
- 12'288 Booleans in [0,1]
- 32'768 in [-1,1]
#kAtMostOne: 256 (#literals: 1'984)
#kBoolOr: 64 (#enforced: 64 #multi: 64) (#literals: 0)
#kExactlyOne: 256 (#literals: 1'024)
#kIntProd: 2'048
#kLinear1: 4'180'286 (#enforced: 4'178'048 #multi: 4'177'984)
#kLinear2: 164'416 (#enforced: 133'696 #multi: 133'696)
#kLinear3: 64 (#enforced: 64 #multi: 64)
#kLinearN: 114'464 (#enforced: 112'384 #multi: 112'384) (#terms: 3'356'480)
This technique, combined with placing mixers on first and last rows and symmetry breaking, allows us to find the solution of the 16x16 balancer in about 55min.
16 x 16 belt balancer
I'm pretty satisfied with the result and I decided to stop here since finding a 32x32 is exponentially harder and will require many more optimizations.
A curious bug
While testing a balancer in-game, I realized it was producing less than the maximum theoretical throughput on some outputs. That was due to a belt not curving as a consequence of an adjacent straight belt. This condition happens only if we're merging flows from two belts without using a mixer, a condition that should never happen in practice.
16 x 16 belt balancer with a bottleneck due to an implicit constraint.
After some debugging, I realized that it's due to the solver stopping when it finds the first feasible solution, without trying to remove unnecessary components and leaving extra belts around where it should leave empty spaces. The solution is simple: add an objective function that penalizes solutions using more components. Finding a slightly more optimal solution than the first feasible one is trivial and doesn't take the solver much time. Counterintuitively, adding an objective function brought down the time for solving a 16x16 to 30min, likely because having an objective skews the solver towards a feasible solution faster than random search.
1objective = sum(
2 [b[i][j] for i in range(W) for j in range(H)] +
3 [5 * m[i][j] for i in range(W) for j in range(H)] +
4 [2 * ua[i][j] for i in range(W) for j in range(H)] +
5 [2 * ub[i][j] for i in range(W) for j in range(H)]
6)
7
8solver.Minimize(objective)
Conclusions
We started this journey building a linear model with continuous variables and having SCIP finding very simple solutions, but could not even crack a 4x4 balancer that is easy to build by hand. Migrating to CP-SAT and applying various optimizations allowed us to obtain an 8x8 balancer that is definitely very hard to build by hand. Splitting the problems in two sub-problems: pre-computing mixer wiring with Banes Networks and placement with CP-SAT allowed us to scale and find the exponentially harder 16x16 balancer that I can confidently say it's impossible to find by hand.
Here's the table of CPU time needed (measured with the time
command) to find a solution on my 12 cores Macbook Pro M4:
Balancer | MIP SCIP | CP-SAT | CP-SAT Banes Networks | CP-SAT Banes Networks memory optimized |
2x2 | 0.06s | 4.94s | 0.17s | 0.19s |
3x3 | -- (Never terminating after few hours) | 22.34s | 22s | 23s |
4x4 | -- | 25.50s | 22s | 19s |
6x6 | -- | -- | 2583s | 2399s |
8x8 | -- | 5669.16s | 1365s | 2168s |
16x16 | -- | -- (Requires too much memory) | -- (Requires too much memory) | 19173s (heuristic initial mixers placement and symmetry breaking) |
It's been an interesting journey and you can find all the code here. We learned how to model for SCIP and CP-SAT solvers. We used both linear and non-linear constraints. We observed how small changes to the model wildly change the solving time. We made a memory/CPU tradeoff to find the 16x16 balancer. And lastly we've seen the role of the objective function in finding a valid solution that works well in practice without having to add additional explicit constraints.