Sharing a River Fairly
When a river runs through several settlements, every liter taken upstream is a liter missing downstream. Each village also has to split its share between drinking water and irrigation for the food its people eat. In this post, I build a small but realistic model of this dilemma, solve it exactly with linear programming in Python, and visualize the results, including a 3D surface that shows how river flow and upstream limits shape the maximum sustainable population.
1. The Example Scenario
A single river passes through six settlements in this order:
| Order | Settlement | Max population (thousand) | Irrigation water per capita |
|---|---|---|---|
| 1 | Up-1 Highland | 20 | 1.2 |
| 2 | Up-2 Terrace | 15 | 1.4 |
| 3 | Up-3 Forest | 10 | 1.6 |
| 4 | Down-1 Plain | 25 | 1.5 |
| 5 | Down-2 Delta | 20 | 1.8 |
| 6 | Down-3 Coast | 15 | 2.0 |
Other conditions:
- Water is measured in abstract flow units, and population in thousands of people.
- The river flow at the source is $Q = 100$.
- Domestic water need per capita is $d = 0.2$.
- A fraction $r = 0.3$ of the withdrawn water returns to the river, so only $(1-r)$ of each withdrawal is truly consumed.
- An environmental minimum flow $E = 10$ must remain in the river at every point.
- Every village must keep at least 20% of its maximum population.
- Optionally, the total upstream withdrawal can be capped by a limit $L$.
2. Mathematical Formulation
Let $P_k$ be the population, $W^{\mathrm{dom}}_k$ the domestic withdrawal, and $W^{\mathrm{agr}}_k$ the irrigation withdrawal of settlement $k$, with total withdrawal $W_k = W^{\mathrm{dom}}_k + W^{\mathrm{agr}}_k$.
$$
\max \sum_{k=1}^{6} P_k
$$
subject to
$$
d,P_k \le W^{\mathrm{dom}}_k, \qquad a_k P_k \le W^{\mathrm{agr}}_k \qquad (k=1,\dots,6)
$$
$$
\sum_{j<k} (1-r),W_j + W_k \le Q - E \qquad (k=1,\dots,6)
$$
$$
\sum_{k \in \text{upstream}} W_k \le L
$$
$$
0.2,P_k^{\max} \le P_k \le P_k^{\max}, \qquad W^{\mathrm{dom}}_k,; W^{\mathrm{agr}}_k \ge 0
$$
The river-balance constraint says that at settlement $k$, the water left in the river after all upstream consumption must still cover this village’s withdrawal while preserving the environmental flow. Because every constraint is linear, the whole problem is a linear program that can be solved exactly.
3. Complete Python Code
The code is a single script. For speed, the constraint matrix is built only once, and only the right-hand side vector changes between the 960 linear programs solved for the sweep, using the fast HiGHS solver.
1 | import time |
4. Code Walkthrough
Section 1: Problem data
The arrays P_max, P_min, and a hold each village’s capacity, guaranteed minimum population, and irrigation water requirement per person. The scalars d, r, and E are the domestic need, return-flow ratio, and environmental flow. The first three villages are upstream, which is encoded by n_up = 3.
Section 2: Building the linear program
The decision vector stacks three blocks of six variables: populations, domestic withdrawals, and irrigation withdrawals, 18 variables in total. The constraint matrix A_UB has $3n+1 = 19$ rows:
- Rows 1 to 6 encode $d P_k - W^{\mathrm{dom}}_k \le 0$.
- Rows 7 to 12 encode $a_k P_k - W^{\mathrm{agr}}_k \le 0$.
- Rows 13 to 18 are the river-balance constraints. For settlement $k$, all upstream withdrawals are multiplied by $(1-r)$ because part of that water flows back, while the settlement’s own withdrawal counts fully.
- Row 19 is the upstream cap $L$.
Since linprog minimizes, the objective vector C is $-1$ for every population variable and $0$ elsewhere, which maximizes total population. The helper solve(Q, L) only rewrites the right-hand side vector b, so the expensive matrix construction happens once. This is the speed-up that makes the 900-point sweep finish in a few seconds.
Section 3: Base case
We solve the problem with $Q=100$ and an effectively unlimited upstream cap (L0 = 1e6). The result is unpacked into populations and withdrawals, shown in a table, and used to compute the river flow after each village with
$$
Q_{k} = Q_{k-1} - (1-r),W_k .
$$
Section 4: Parameter sweep
L_min is the smallest upstream cap that still satisfies the 20% minimum guarantee for upstream villages. We then solve the LP on a $30 \times 30$ grid of river flow $Q \in [60,160]$ and cap $L \in [L_{\min},100]$, storing the optimal population in Z. Infeasible cases would remain NaN, which keeps the code safe. A second loop traces the trade-off curve at $Q=100$.
Section 5: Visualization
All five charts are drawn into one figure using GridSpec. Panels (a) to (c) describe the base case, panel (d) is the 3D surface, and panel (e) is the trade-off curve between upstream and downstream population.
5. Where to Paste the Results
================================================================== Budget: $100k | Total expected annual loss without controls: $1750.0k ================================================================== [Optimal allocation] Firewall $ 14.40k ( 14.4%) IDS/IPS $ 20.26k ( 20.3%) Employee Training $ 27.17k ( 27.2%) Penetration Testing $ 7.55k ( 7.5%) EDR $ 15.62k ( 15.6%) Patch Management $ 15.00k ( 15.0%) Residual expected loss : $770.35k Risk reduction : 55.98% [Strategy comparison] Equal split residual $ 791.65k | reduction 54.76% Risk-weighted split residual $ 782.48k | reduction 55.29% Random search (50,000) residual $ 770.78k | reduction 55.96% Greedy ($1k steps) residual $ 770.38k | reduction 55.98% SLSQP optimum residual $ 770.35k | reduction 55.98% [Marginal loss reduction per extra $1k at the optimum] Firewall 4.6685 IDS/IPS 4.6685 Employee Training 4.6685 Penetration Testing 4.6685 EDR 4.6685 Patch Management 4.6685 [Budget sweep] Budget $ 20k -> residual $ 1404.18k ( 19.8% reduction) Budget $ 50k -> residual $ 1085.86k ( 38.0% reduction) Budget $ 80k -> residual $ 874.84k ( 50.0% reduction) Budget $110k -> residual $ 726.10k ( 58.5% reduction) Budget $140k -> residual $ 618.23k ( 64.7% reduction) Budget $170k -> residual $ 538.56k ( 69.2% reduction) Budget $200k -> residual $ 478.78k ( 72.6% reduction) /tmp/ipykernel_962/2916647809.py:224: UserWarning: Tight layout not applied. The left and right margins cannot be made large enough to accommodate all Axes decorations. fig.tight_layout(rect=[0, 0, 1, 0.96])

6. Interpreting the Results
Base case: who gets the water?
The optimal plan supports a total of about 76.2 thousand people: roughly 43.8 thousand upstream and 32.4 thousand downstream. Three observations stand out.
- Upstream villages are filled first. Highland and Terrace reach their full capacity, and Forest reaches about 8.8 thousand of its 10 thousand. This is not favoritism. Because 30% of upstream water returns to the river, one unit withdrawn upstream costs the system only $(1-r)=0.7$ units of consumption, while the village’s need per person is among the lowest. Using water where it is cheapest to turn into population maximizes the total.
- Down-1 Plain also fills completely, thanks to its relatively low irrigation need of 1.5 per person, while Delta and Coast, with needs of 1.8 and 2.0, end up near the lower end of their allowed range (about 4.4 and 3.0 thousand). The 20% guarantee is what keeps Coast at 3.0 thousand rather than zero.
- The river is nearly exhausted at the mouth. Panel (a) shows the flow dropping step by step from 100 to about 12, just above the environmental minimum of 10. Water is the binding resource, and the plan uses it almost completely.
Panels (b) and (c)
Panel (b) compares the optimal population with each village’s capacity. Panel (c) shows that irrigation takes the overwhelming share of withdrawals in every village, roughly six to eight times the domestic use. This is typical of real river basins, where agriculture dominates water demand, and it explains why differences in irrigation efficiency drive the optimal allocation far more than differences in drinking-water needs.
3D surface: panel (d)
The surface shows the maximum total population as a function of river flow $Q$ and the upstream withdrawal limit $L$.
- Along the $Q$ axis, population rises almost linearly at first, because every additional unit of water directly supports more people, and then flattens out as villages hit their capacity. The maximum on the surface is about 105 thousand, which is the sum of all capacities.
- Along the $L$ axis, a very strict upstream limit lowers the maximum population, because water that could be used efficiently upstream is forced to flow down to less efficient villages.
- Beyond a certain limit, increasing $L$ no longer changes anything, which shows up as a flat plateau. At that point the upstream limit is no longer binding.
Trade-off curve: panel (e)
At $Q=100$, the total population is about 64.2 thousand under the strictest limit and climbs to about 76.2 thousand once the limit reaches roughly 68. Upstream population grows with $L$ while downstream population shrinks, and the total keeps rising until the upstream limit stops being binding. This illustrates a useful policy insight: a moderate upstream cap costs the basin very little in total population, but it shifts a meaningful number of people from the upstream to the downstream side. Policy makers who care about fairness can therefore choose a cap in the range where the total curve is still nearly flat, trading a small loss in total population for a much more balanced distribution.
7. Conclusion
By expressing the upstream-downstream water conflict as a linear program, we obtained a provably optimal allocation of domestic and agricultural water, an exact measure of how much each unit of upstream limitation costs in total population, and a clear picture of how river flow and return-flow efficiency shape the maximum sustainable population. The same framework scales easily: adding more villages, seasonal flows, or different return-flow ratios only requires changing the data arrays and the constraint matrix.











