Mixed integer linear programming (MILP) approach to find an optimal investment order for cycles#1004
Mixed integer linear programming (MILP) approach to find an optimal investment order for cycles#1004
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1004 +/- ##
==========================================
+ Coverage 84.12% 84.26% +0.14%
==========================================
Files 52 52
Lines 5844 5974 +130
Branches 5844 5974 +130
==========================================
+ Hits 4916 5034 +118
- Misses 689 695 +6
- Partials 239 245 +6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
3cd0282 to
a19a151
Compare
dalonsoa
left a comment
There was a problem hiding this comment.
It clearly works - at least for the example given - and I think I follow the logic, but it takes quite a bit of effort to understand what's going on. I've left a couple of comments.
Maybe it will be useful to include explicitly in the docstring the matrix being solved, with all its constrains (maybe reduce the system to 4 nodes instead of 5, so you have less elements to worry about)
src/graph/investment.rs
Outdated
| /// with multiple members require an internal ordering so that the investment algorithm can treat | ||
| /// them as near-acyclic chains, minimising potential disruption. | ||
| /// | ||
| /// To rank the members of each multi-node component, we construct a mixed integer linear program: |
There was a problem hiding this comment.
I would include here a link to somewhere where this method is explained. Otherwise, the following discussion on antisymmetry, transitivity, etc. it is pretty hard to follow.
| for edge in original_graph.edges_directed(idx, Direction::Outgoing) { | ||
| if let Some(&j) = index_position.get(&edge.target()) { | ||
| penalties[i][j] = 1.0; | ||
| } else { | ||
| has_external_outgoing[i] = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
This might need some inline comments, for clarity. Something like:
| for edge in original_graph.edges_directed(idx, Direction::Outgoing) { | |
| if let Some(&j) = index_position.get(&edge.target()) { | |
| penalties[i][j] = 1.0; | |
| } else { | |
| has_external_outgoing[i] = true; | |
| } | |
| } | |
| // We loop over the edges going out of this node | |
| for edge in original_graph.edges_directed(idx, Direction::Outgoing) { | |
| if let Some(&j) = index_position.get(&edge.target()) { | |
| // If they lead to another node within the SCC, we apply a penalty | |
| penalties[i][j] = 1.0; | |
| } else { | |
| // Otherwise they lead to an external node so we take note of it | |
| has_external_outgoing[i] = true; | |
| } | |
| } |
| for (j, _) in row.iter().enumerate().skip(i + 1) { | ||
| let Some(x_ij) = vars[i][j] else { continue }; | ||
| let Some(x_ji) = vars[j][i] else { continue }; | ||
| problem.add_row(1.0..=1.0, [(x_ij, 1.0), (x_ji, 1.0)]); |
There was a problem hiding this comment.
If I get this right, this enforces that 1 = x_ij + x_ij. However,
- What enforces that
x_ijandx_jiare not float numbers?x_ij = x_ji = 0.5would be a valid solution, right? - What enforces that one of them is not negative, eg.
x_ij = -5and x_ji = 6`
In summary, it seems that the x are meant to be 0 or 1, but I do not see where that is enforced. You mention binary variables, but don't know where that happens.
| continue; | ||
| } | ||
| let cost = penalties[i][j]; | ||
| *slot = Some(problem.add_integer_column(cost, 0..=1)); |
There was a problem hiding this comment.
Oh, wait, is it here where you enforce that by using an integer column with value between 0 and 1... and so, only 0 and 1 are valid options?
dalonsoa
left a comment
There was a problem hiding this comment.
Very nice! I think this is way clearer now.
Description
This PR introduces a mixed integer linear programming (MILP) approach to find an optimal investment order for cycles.
Just to give a bit of background/reminder on the current approach for finding the investment order in complete systems. The first step is to build a graph where the nodes are markets (i.e. a specific combination of commodity and region, such as the ELCTRI market in GBR), and the edges are processes. An edge exists from node A to node B if there exists a process that can convert A to B (currently these edges are all literal processes, but in the future we may introduce trade edges, e.g. COAL in GBR to COAL in FRA). In a graph with no cycles, we can find an investment order with no "conflicts" by doing a reverse topological sort. By "lack of conflicts" I mean that markets further along the supply chain will always be solved earlier.
If a system contains a cycle, then it's not possible to create an investment order with no conflicts - whatever order we come up with, there will have to be at least one pairwise violation, i.e. where market X is solved before market Y despite Y being a consumer of X. Such a conflict may then require us to redo investments for X after we've solved Y if the demand profile for X is significantly influenced by Y. Since this is extra work we'd like to minimise the number of times we potentially have to do this.
So far, I've written code to identify SCCs and compress them into "supernodes". The resulting condensed graph is guaranteed to have no cycles, so we can perform a topological sort as normal, where these "supernodes" as a whole will appear somewhere in the order. But the question remains as to what we do when a supernode is reached in the investment algorithm - currently we just exit the simulation.
An optimal approach is going to have two components:
I've tried a few approaches for the ordering problem, and have had the most success with a mixed integer linear programming (MILP) approach using highs, which is what I'm proposing in this PR (big credit to ChatGPT for suggesting this, but there seems to be a lot of literature on this approach as well)
The MILP approach builds a set of binary decision variables x_i,j over all combinations of i and j (nodes in the SCC), which is 1 if i comes before j in the ordering, or 0 otherwise. Constraints are build in such a way that these decision variables must be self-consistent:
We apply costs to x_i,j based on the connections within the SCC:
Additionally, we want to encourage, nodes with connections going out of the SCC to come earlier in the ordering (the SCC needs to "see" any external demands early on, otherwise there will be no demand for investment):
The solver then optimises x_i,j to minimise penalty_i,j * x_i,j, with the constraints to ensure that x_i,j is self-consistent. We can then work out the optimal position of each i in the order by summing over all x_i,j (i.e. the number of times it appears before other nodes in all pairwise orderings). We then save this order within the
InvestmentSet::Cycle, so that when the investment algorithm reaches the supernode, it will solve investments in this order (this will be another PR).Some considerations:
See also #999
Fixes # (issue)
Type of change
Key checklist
$ cargo test$ cargo docFurther checks
Note
Introduce HiGHS-backed MILP to order nodes within SCCs, integrate into cycle compression, and add tests; make InvestmentSet hashable.
highsto minimise forward edges within cycles, with a small external-export bias.compress_cycles(&InvestmentGraph)and subsequent topological layering.EqandHashforInvestmentSetto support lookup during SCC ordering.test_order_sccs_simple_cyclevalidating MILP ordering on a toy cycle.Written by Cursor Bugbot for commit a19a151. This will update automatically on new commits. Configure here.