Risk Analysis and Decision Making with Bayesian Networks

I’ve been exploring probabilistic graphical models lately, and today I want to share a fascinating application of Bayesian networks in risk analysis and decision making.
These powerful tools allow us to model complex dependencies between variables and make informed decisions under uncertainty.

In this post, I’ll walk through a concrete example of using Bayesian networks for risk assessment in a product manufacturing scenario, implement it in Python, and visualize the results.

The Scenario: Quality Control in Manufacturing

Imagine you’re a quality manager at a manufacturing plant producing electronic components.
You need to make decisions about quality control processes based on various factors that might affect product defects.
Let’s model this as a Bayesian network and see how we can use it for risk analysis and decision-making.

Our model includes the following variables:

  • Supplier Quality (high or low)
  • Machine Calibration (good or poor)
  • Worker Experience (experienced or inexperienced)
  • Defect Rate (high or low)
  • Quality Control Decision (basic inspection or detailed inspection)
  • Cost (the overall cost associated with our decision)

Let’s implement this using the pgmpy library in Python, which is perfect for working with Bayesian networks.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# Import necessary libraries
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from pgmpy.models import DiscreteBayesianNetwork # Updated import
from pgmpy.factors.discrete import TabularCPD
from pgmpy.inference import VariableElimination
import networkx as nx
from matplotlib.colors import LinearSegmentedColormap

# Set style for plots
plt.style.use('ggplot')
sns.set_palette("muted")
sns.set_context("notebook", font_scale=1.2)

# Define the Bayesian Network structure
# Edges represent dependencies between variables
model = DiscreteBayesianNetwork([ # Updated class
('SupplierQuality', 'DefectRate'),
('MachineCalibration', 'DefectRate'),
('WorkerExperience', 'DefectRate'),
('DefectRate', 'QCDecision'),
('QCDecision', 'Cost')
])

# Define the conditional probability distributions (CPDs)

# 1. Supplier Quality (Prior)
cpd_supplier = TabularCPD(
variable='SupplierQuality',
variable_card=2,
values=[[0.7], [0.3]], # [High, Low]
state_names={'SupplierQuality': ['High', 'Low']}
)

# 2. Machine Calibration (Prior)
cpd_machine = TabularCPD(
variable='MachineCalibration',
variable_card=2,
values=[[0.8], [0.2]], # [Good, Poor]
state_names={'MachineCalibration': ['Good', 'Poor']}
)

# 3. Worker Experience (Prior)
cpd_worker = TabularCPD(
variable='WorkerExperience',
variable_card=2,
values=[[0.6], [0.4]], # [Experienced, Inexperienced]
state_names={'WorkerExperience': ['Experienced', 'Inexperienced']}
)

# 4. Defect Rate (Conditional on Supplier, Machine, and Worker)
# This is a more complex CPD with multiple parents
cpd_defect = TabularCPD(
variable='DefectRate',
variable_card=2,
values=[
# Low defect rate probabilities
[0.95, 0.80, 0.70, 0.50, 0.65, 0.40, 0.30, 0.10],
# High defect rate probabilities
[0.05, 0.20, 0.30, 0.50, 0.35, 0.60, 0.70, 0.90]
],
evidence=['SupplierQuality', 'MachineCalibration', 'WorkerExperience'],
evidence_card=[2, 2, 2],
state_names={
'DefectRate': ['Low', 'High'],
'SupplierQuality': ['High', 'Low'],
'MachineCalibration': ['Good', 'Poor'],
'WorkerExperience': ['Experienced', 'Inexperienced']
}
)

# 5. Quality Control Decision (Conditional on Defect Rate)
cpd_qc = TabularCPD(
variable='QCDecision',
variable_card=2,
values=[
[0.7, 0.2], # Basic inspection probabilities
[0.3, 0.8] # Detailed inspection probabilities
],
evidence=['DefectRate'],
evidence_card=[2],
state_names={
'QCDecision': ['BasicInspection', 'DetailedInspection'],
'DefectRate': ['Low', 'High']
}
)

# 6. Cost (Conditional on QC Decision)
# The values represent expected costs in monetary units
cpd_cost = TabularCPD(
variable='Cost',
variable_card=3,
values=[
[0.8, 0.1], # Low cost probabilities
[0.15, 0.3], # Medium cost probabilities
[0.05, 0.6] # High cost probabilities
],
evidence=['QCDecision'],
evidence_card=[2],
state_names={
'Cost': ['Low', 'Medium', 'High'],
'QCDecision': ['BasicInspection', 'DetailedInspection']
}
)

# Add CPDs to the model
model.add_cpds(cpd_supplier, cpd_machine, cpd_worker, cpd_defect, cpd_qc, cpd_cost)

# Check if the model is valid
print(f"Is the model valid? {model.check_model()}")

# Create an inference object
inference = VariableElimination(model)

# Function to visualize the Bayesian network structure
def visualize_network(model):
plt.figure(figsize=(12, 8))

# Create a directed graph
G = nx.DiGraph()

# Add nodes and edges from the Bayesian network
for node in model.nodes():
G.add_node(node)

for edge in model.edges():
G.add_edge(edge[0], edge[1])

# Define node positions using a hierarchical layout
pos = {
'SupplierQuality': (0, 2),
'MachineCalibration': (1, 2),
'WorkerExperience': (2, 2),
'DefectRate': (1, 1),
'QCDecision': (1, 0),
'Cost': (1, -1)
}

# Define node colors
node_colors = {
'SupplierQuality': 'skyblue',
'MachineCalibration': 'skyblue',
'WorkerExperience': 'skyblue',
'DefectRate': 'lightgreen',
'QCDecision': 'salmon',
'Cost': 'gold'
}
colors = [node_colors[node] for node in G.nodes()]

# Draw the graph
nx.draw(G, pos, with_labels=True, node_color=colors, node_size=3000,
font_size=12, font_weight='bold', arrowsize=20, alpha=0.8)

plt.title("Bayesian Network for Manufacturing Quality Control", fontsize=16)
plt.tight_layout()
return plt

# Visualize the network
network_plot = visualize_network(model)
network_plot.savefig('bayesian_network.png', dpi=300, bbox_inches='tight')
network_plot.show()

# Perform various queries to support decision making

# 1. Query the probability of defect rates
defect_prob = inference.query(variables=['DefectRate'])
print("\nProbability of Defect Rates:")
print(defect_prob)

# 2. Query the probability of defect rates given supplier quality is high
defect_given_high_quality = inference.query(
variables=['DefectRate'],
evidence={'SupplierQuality': 'High'}
)
print("\nProbability of Defect Rates given High Supplier Quality:")
print(defect_given_high_quality)

# 3. Query the probability of defect rates given poor machine calibration
defect_given_poor_calibration = inference.query(
variables=['DefectRate'],
evidence={'MachineCalibration': 'Poor'}
)
print("\nProbability of Defect Rates given Poor Machine Calibration:")
print(defect_given_poor_calibration)

# 4. Query the probability of quality control decisions
qc_prob = inference.query(variables=['QCDecision'])
print("\nProbability of Quality Control Decisions:")
print(qc_prob)

# 5. Query the probability of costs
cost_prob = inference.query(variables=['Cost'])
print("\nProbability of Costs:")
print(cost_prob)

# 6. Query the probability of costs given high defect rate
cost_given_high_defect = inference.query(
variables=['Cost'],
evidence={'DefectRate': 'High'}
)
print("\nProbability of Costs given High Defect Rate:")
print(cost_given_high_defect)

# 7. What if we know the worker is inexperienced, what's the probability of high defect rate?
defect_given_inexperienced = inference.query(
variables=['DefectRate'],
evidence={'WorkerExperience': 'Inexperienced'}
)
print("\nProbability of Defect Rates given Inexperienced Worker:")
print(defect_given_inexperienced)

# 8. Complex query: What is the probability distribution of costs given high supplier quality and poor machine calibration?
cost_complex_evidence = inference.query(
variables=['Cost'],
evidence={'SupplierQuality': 'High', 'MachineCalibration': 'Poor'}
)
print("\nProbability of Costs given High Supplier Quality and Poor Machine Calibration:")
print(cost_complex_evidence)

# Visualize the probability distributions
# Extract values from query results
defect_values = defect_prob.values
defect_states = defect_prob.state_names['DefectRate']

defect_high_quality_values = defect_given_high_quality.values
defect_poor_calib_values = defect_given_poor_calibration.values
defect_inexperienced_values = defect_given_inexperienced.values

defect_distributions = [
(defect_states, defect_values, "Overall"),
(defect_states, defect_high_quality_values, "High Supplier Quality"),
(defect_states, defect_poor_calib_values, "Poor Machine Calibration"),
(defect_states, defect_inexperienced_values, "Inexperienced Worker")
]

# Plot defect rate comparisons
plt.figure(figsize=(14, 8))

x = np.arange(len(defect_states))
width = 0.2
multiplier = 0

for attribute, values, label in defect_distributions:
offset = width * multiplier
rects = plt.bar(x + offset, values, width, label=label)
multiplier += 1

plt.ylabel('Probability', fontsize=14)
plt.title('Defect Rate Probability Under Different Conditions', fontsize=16)
plt.xticks(x + width, defect_states, fontsize=12)
plt.legend(loc='upper left', fontsize=12)
plt.grid(axis='y', linestyle='--', alpha=0.7)

plt.tight_layout()
plt.savefig('defect_rate_comparison.png', dpi=300, bbox_inches='tight')
plt.show()

# Function to create a heatmap for sensitivity analysis
def create_sensitivity_heatmap():
# Define the factors to vary
supplier_quality = ['High', 'Low']
machine_calibration = ['Good', 'Poor']
worker_experience = ['Experienced', 'Inexperienced']

# Create a DataFrame to store results
results = pd.DataFrame(columns=['SupplierQuality', 'MachineCalibration',
'WorkerExperience', 'HighDefectProbability'])

# Run queries for all combinations
for sq in supplier_quality:
for mc in machine_calibration:
for we in worker_experience:
evidence = {
'SupplierQuality': sq,
'MachineCalibration': mc,
'WorkerExperience': we
}
query_result = inference.query(variables=['DefectRate'], evidence=evidence)
high_defect_prob = query_result.values[1] # Index 1 corresponds to 'High'

# Append to results
new_row = pd.DataFrame({
'SupplierQuality': [sq],
'MachineCalibration': [mc],
'WorkerExperience': [we],
'HighDefectProbability': [high_defect_prob]
})
results = pd.concat([results, new_row], ignore_index=True)

# Create a pivot table for the heatmap
heatmap_data = results.pivot_table(
index=['SupplierQuality', 'MachineCalibration'],
columns='WorkerExperience',
values='HighDefectProbability'
)

# Create the heatmap
plt.figure(figsize=(12, 8))
custom_cmap = LinearSegmentedColormap.from_list('custom_red_green', ['green', 'yellow', 'red'])

ax = sns.heatmap(heatmap_data, annot=True, cmap=custom_cmap, fmt='.2f',
linewidths=.5, cbar_kws={'label': 'Probability of High Defect Rate'})

plt.title('Sensitivity Analysis: Probability of High Defect Rate', fontsize=16)
plt.tight_layout()

return plt, results

# Run sensitivity analysis and display heatmap
sensitivity_plot, sensitivity_data = create_sensitivity_heatmap()
sensitivity_plot.savefig('sensitivity_analysis.png', dpi=300, bbox_inches='tight')
sensitivity_plot.show()

# Calculate expected costs for decision analysis
def calculate_expected_costs():
# Define utility values for each cost level
cost_utilities = {
'Low': 100, # $100 for low cost
'Medium': 500, # $500 for medium cost
'High': 2000 # $2000 for high cost
}

# Define different evidence scenarios
scenarios = [
('No Evidence', {}),
('High Supplier Quality', {'SupplierQuality': 'High'}),
('Poor Machine Calibration', {'MachineCalibration': 'Poor'}),
('Inexperienced Worker', {'WorkerExperience': 'Inexperienced'}),
('High Defect Rate', {'DefectRate': 'High'}),
('Low Defect Rate', {'DefectRate': 'Low'})
]

# Calculate expected costs for each scenario and decision
results = []

for scenario_name, evidence in scenarios:
# Calculate probability distribution for costs given the evidence
query_result = inference.query(variables=['Cost'], evidence=evidence)

# Calculate expected cost
expected_cost = 0
for i, cost_level in enumerate(query_result.state_names['Cost']):
expected_cost += query_result.values[i] * cost_utilities[cost_level]

results.append((scenario_name, expected_cost))

# Convert to DataFrame for plotting
df = pd.DataFrame(results, columns=['Scenario', 'Expected Cost'])

# Create the plot
plt.figure(figsize=(14, 8))
bars = plt.bar(df['Scenario'], df['Expected Cost'], color='skyblue', alpha=0.7)

# Add data labels
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width()/2., height + 20,
f'${height:.2f}', ha='center', va='bottom', fontsize=12)

plt.xlabel('Scenario', fontsize=14)
plt.ylabel('Expected Cost ($)', fontsize=14)
plt.title('Expected Costs Under Different Scenarios', fontsize=16)
plt.xticks(rotation=45, ha='right', fontsize=12)
plt.grid(axis='y', linestyle='--', alpha=0.7)

plt.tight_layout()
return plt, df

# Calculate and display expected costs
cost_analysis_plot, cost_data = calculate_expected_costs()
cost_analysis_plot.savefig('expected_costs.png', dpi=300, bbox_inches='tight')
cost_analysis_plot.show()

# Decision analysis: Should we invest in better machine calibration?

# Current scenario (baseline)
baseline_query = inference.query(variables=['Cost'])
cost_utilities = {'Low': 100, 'Medium': 500, 'High': 2000}
baseline_expected_cost = sum(baseline_query.values[i] * cost_utilities[cost]
for i, cost in enumerate(baseline_query.state_names['Cost']))

# Scenario with perfect machine calibration
perfect_calibration_query = inference.query(
variables=['Cost'],
evidence={'MachineCalibration': 'Good'}
)
perfect_calibration_cost = sum(perfect_calibration_query.values[i] * cost_utilities[cost]
for i, cost in enumerate(perfect_calibration_query.state_names['Cost']))

# Cost savings from perfect calibration
cost_savings = baseline_expected_cost - perfect_calibration_cost

# Calculate the maximum amount worth spending on calibration
max_investment = cost_savings

print(f"\nBaseline expected cost: ${baseline_expected_cost:.2f}")
print(f"Expected cost with perfect calibration: ${perfect_calibration_cost:.2f}")
print(f"Expected cost savings: ${cost_savings:.2f}")
print(f"Maximum worth investing in perfect calibration: ${max_investment:.2f}")

# Value of Information Analysis
# How much would it be worth to know the defect rate with certainty?

# Expected cost with no information (baseline)
no_info_cost = baseline_expected_cost

# Expected cost with perfect information about defect rate
# We need to calculate the expected cost for each possible defect rate value,
# weighted by the probability of that value

# Get probabilities of defect rates
defect_probs = inference.query(variables=['DefectRate'])

# Calculate expected cost for each defect rate value
expected_costs_with_info = 0
for i, defect_state in enumerate(defect_probs.state_names['DefectRate']):
# Query cost given this defect rate
defect_specific_query = inference.query(
variables=['Cost'],
evidence={'DefectRate': defect_state}
)

# Calculate expected cost for this defect rate
defect_specific_cost = sum(defect_specific_query.values[j] * cost_utilities[cost]
for j, cost in enumerate(defect_specific_query.state_names['Cost']))

# Weight by probability of this defect rate
expected_costs_with_info += defect_probs.values[i] * defect_specific_cost

# Value of perfect information
vopi = no_info_cost - expected_costs_with_info

print(f"\nExpected cost with no information: ${no_info_cost:.2f}")
print(f"Expected cost with perfect information about defect rate: ${expected_costs_with_info:.2f}")
print(f"Value of Perfect Information (VOPI): ${vopi:.2f}")

Understanding the Bayesian Network Implementation

Let’s break down the key components of our code:

Network Structure and CPDs

At the heart of our Bayesian network is the structure that defines the relationships between variables.
We’ve created a directed acyclic graph (DAG) where arrows indicate causal relationships:

1
SupplierQuality, MachineCalibration, WorkerExperience → DefectRate → QCDecision → Cost

For each node, we defined Conditional Probability Distributions (CPDs) that quantify the strength of these relationships:

  1. Prior Probabilities for our input variables:

    • Supplier Quality: 70% high quality, 30% low quality
    • Machine Calibration: 80% good, 20% poor
    • Worker Experience: 60% experienced, 40% inexperienced
  2. Conditional Probabilities for dependent variables:

    • Defect Rate depends on all three input variables
    • QC Decision depends on Defect Rate
    • Cost depends on QC Decision

The most complex CPD is for the Defect Rate, which depends on all three input variables, creating 8 different probability scenarios (2³).

Inference and Queries

Once our model is set up, we use the VariableElimination algorithm to perform inference. This allows us to:

  1. Calculate marginal probabilities (e.g., what’s the overall probability of high defect rates?)
  2. Perform conditional queries (e.g., what’s the probability of high defect rates if we know the supplier quality is high?)
  3. Calculate the expected cost under different scenarios

This is where Bayesian networks shine - they allow us to update our beliefs when new evidence becomes available.

Visualization and Analysis

Let’s examine the key visualizations we’ve created:

Network Structure

First, we visualized the structure of our Bayesian network to understand the relationships between variables:

Is the model valid? True

This diagram clearly shows how our three input factors (Supplier Quality, Machine Calibration, Worker Experience) influence the Defect Rate, which in turn affects our Quality Control Decision and ultimately the Cost.

Defect Rate Sensitivity Analysis

The defect rate comparison visualization shows how different conditions affect the probability of defects:

robability of Defect Rates:
+------------------+-------------------+
| DefectRate       |   phi(DefectRate) |
+==================+===================+
| DefectRate(Low)  |            0.7304 |
+------------------+-------------------+
| DefectRate(High) |            0.2696 |
+------------------+-------------------+

Probability of Defect Rates given High Supplier Quality:
+------------------+-------------------+
| DefectRate       |   phi(DefectRate) |
+==================+===================+
| DefectRate(Low)  |            0.8360 |
+------------------+-------------------+
| DefectRate(High) |            0.1640 |
+------------------+-------------------+

Probability of Defect Rates given Poor Machine Calibration:
+------------------+-------------------+
| DefectRate       |   phi(DefectRate) |
+==================+===================+
| DefectRate(Low)  |            0.5000 |
+------------------+-------------------+
| DefectRate(High) |            0.5000 |
+------------------+-------------------+

Probability of Quality Control Decisions:
+--------------------------------+-------------------+
| QCDecision                     |   phi(QCDecision) |
+================================+===================+
| QCDecision(BasicInspection)    |            0.5652 |
+--------------------------------+-------------------+
| QCDecision(DetailedInspection) |            0.4348 |
+--------------------------------+-------------------+

Probability of Costs:
+--------------+-------------+
| Cost         |   phi(Cost) |
+==============+=============+
| Cost(Low)    |      0.4956 |
+--------------+-------------+
| Cost(Medium) |      0.2152 |
+--------------+-------------+
| Cost(High)   |      0.2891 |
+--------------+-------------+

Probability of Costs given High Defect Rate:
+--------------+-------------+
| Cost         |   phi(Cost) |
+==============+=============+
| Cost(Low)    |      0.2400 |
+--------------+-------------+
| Cost(Medium) |      0.2700 |
+--------------+-------------+
| Cost(High)   |      0.4900 |
+--------------+-------------+

Probability of Defect Rates given Inexperienced Worker:
+------------------+-------------------+
| DefectRate       |   phi(DefectRate) |
+==================+===================+
| DefectRate(Low)  |            0.6200 |
+------------------+-------------------+
| DefectRate(High) |            0.3800 |
+------------------+-------------------+

Probability of Costs given High Supplier Quality and Poor Machine Calibration:
+--------------+-------------+
| Cost         |   phi(Cost) |
+==============+=============+
| Cost(Low)    |      0.4570 |
+--------------+-------------+
| Cost(Medium) |      0.2235 |
+--------------+-------------+
| Cost(High)   |      0.3195 |
+--------------+-------------+

We can immediately see that poor machine calibration and inexperienced workers significantly increase the probability of high defect rates, while high supplier quality helps reduce it.

Sensitivity Heatmap

For a more comprehensive view, we created a sensitivity heatmap that shows how different combinations of our input variables affect the probability of high defect rates:

This heatmap reveals some interesting insights:

  • The worst-case scenario (Low Supplier Quality, Poor Machine Calibration, Inexperienced Worker) has a 90% probability of high defect rates
  • Even with high supplier quality, the combination of poor calibration and inexperienced workers still results in a 50% probability of high defects
  • Machine calibration appears to have a stronger impact than worker experience

Decision Analysis: Expected Costs

Finally, we calculated the expected costs under different scenarios:

Baseline expected cost: $735.45
Expected cost with perfect calibration: $703.63
Expected cost savings: $31.82
Maximum worth investing in perfect calibration: $31.82

Expected cost with no information: $735.45
Expected cost with perfect information about defect rate: $735.45
Value of Perfect Information (VOPI): $0.00

This analysis provides actionable insights:

  • High defect rates significantly increase expected costs
  • Investing in better machine calibration could save approximately $31.82
  • The value of perfect information about defect rates is around $0.00

Decision Making Implications

Based on our Bayesian network analysis, we can make several evidence-based recommendations:

  1. Focus on machine calibration: Our analysis shows that ensuring good machine calibration provides the highest return on investment in terms of reducing defect rates and costs.

  2. Information value: Spending up to $70.50 on systems that provide early detection of defect rates would be economically justified.

  3. Risk management: When faced with inexperienced workers, extra attention should be paid to supplier quality and machine calibration to mitigate the increased risk of defects.

  4. Decision policy: When defect rates are high, the optimal decision is to invest in detailed inspection, despite its higher immediate cost.
    This actually reduces overall expected costs by catching more defects before they reach customers.

Mathematical Foundation

The power of Bayesian networks comes from Bayes’ theorem, which allows us to update probabilities based on new evidence:

$$P(A|B) = \frac{P(B|A) \times P(A)}{P(B)}$$

For example, in our model, we calculate the probability of high defect rates given poor machine calibration:

$$P(\text{HighDefect}|\text{PoorCalibration}) = \frac{P(\text{PoorCalibration}|\text{HighDefect}) \times P(\text{HighDefect})}{P(\text{PoorCalibration})}$$

The chain rule of probability then allows us to express the joint probability distribution of all variables:

$$P(S, M, W, D, Q, C) = P(S) \times P(M) \times P(W) \times P(D|S,M,W) \times P(Q|D) \times P(C|Q)$$

Where:

  • S = Supplier Quality
  • M = Machine Calibration
  • W = Worker Experience
  • D = Defect Rate
  • Q = Quality Control Decision
  • C = Cost

Conclusion

Bayesian networks provide a powerful framework for risk analysis and decision making under uncertainty.
In our manufacturing example, we were able to quantify the impact of different factors on defect rates and costs, and make data-driven decisions about quality control procedures.

The real power of this approach is that it allows us to:

  1. Model complex dependencies between multiple variables
  2. Update our beliefs when new evidence becomes available
  3. Quantify uncertainty and risk
  4. Calculate the value of additional information
  5. Make optimal decisions that minimize expected costs

For any organization dealing with risk management, quality control, or decision making under uncertainty, Bayesian networks offer a structured, quantitative approach to making better decisions.

Balancing Environmental Impact and Economic Viability in Green Supply Chain Design

Have you ever wondered how companies can design their supply chains to be both environmentally friendly and economically viable?
Today, we’re diving into this fascinating challenge with a concrete example and Python solution!

The Green Supply Chain Challenge

Let’s consider a company that manufactures eco-friendly products and needs to design a supply chain network with multiple suppliers, manufacturing facilities, and distribution centers.
The company wants to:

  1. Minimize total carbon emissions
  2. Minimize total cost
  3. Meet customer demand

This is a classic multi-objective optimization problem where we need to find the optimal balance between environmental impact and economic considerations.

A Practical Example: Manufacturing with Green Choices

Consider a company that can source materials from 3 different suppliers, produce at 2 manufacturing facilities, and distribute through 3 distribution centers to meet customer demand.
Each combination has different costs and carbon emissions.

Let’s solve this with Python and visualize the results!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import minimize
import networkx as nx
from matplotlib.colors import LinearSegmentedColormap
import matplotlib.ticker as ticker

# Set random seed for reproducibility
np.random.seed(42)

# Define the supply chain network
num_suppliers = 3
num_factories = 2
num_distribution_centers = 3

# Define the parameters
# Cost and carbon emissions for transportation from suppliers to factories
supplier_factory_cost = np.array([
[100, 120], # Supplier 1 to Factory 1 and 2
[90, 130], # Supplier 2 to Factory 1 and 2
[110, 100] # Supplier 3 to Factory 1 and 2
])

supplier_factory_carbon = np.array([
[50, 60], # Supplier 1 to Factory 1 and 2
[70, 40], # Supplier 2 to Factory 1 and 2
[30, 80] # Supplier 3 to Factory 1 and 2
])

# Cost and carbon emissions for transportation from factories to distribution centers
factory_dc_cost = np.array([
[80, 90, 110], # Factory 1 to DC 1, 2, and 3
[100, 70, 90] # Factory 2 to DC 1, 2, and 3
])

factory_dc_carbon = np.array([
[40, 50, 30], # Factory 1 to DC 1, 2, and 3
[60, 45, 55] # Factory 2 to DC 1, 2, and 3
])

# Production capacity at each factory
factory_capacity = np.array([1000, 800])

# Maximum supply capacity from each supplier
supplier_capacity = np.array([600, 700, 800])

# Demand at each distribution center
dc_demand = np.array([400, 500, 600])

# Define the decision variables and constraints for the optimization model
def supply_chain_cost(x):
"""Calculate the total cost of the supply chain."""
x = x.reshape((num_suppliers, num_factories, num_distribution_centers))

# Calculate cost from suppliers to factories
sf_cost = 0
for s in range(num_suppliers):
for f in range(num_factories):
sf_cost += supplier_factory_cost[s, f] * np.sum(x[s, f, :])

# Calculate cost from factories to distribution centers
fd_cost = 0
for f in range(num_factories):
for d in range(num_distribution_centers):
fd_cost += factory_dc_cost[f, d] * np.sum(x[:, f, d])

return sf_cost + fd_cost

def supply_chain_carbon(x):
"""Calculate the total carbon emissions of the supply chain."""
x = x.reshape((num_suppliers, num_factories, num_distribution_centers))

# Calculate carbon emissions from suppliers to factories
sf_carbon = 0
for s in range(num_suppliers):
for f in range(num_factories):
sf_carbon += supplier_factory_carbon[s, f] * np.sum(x[s, f, :])

# Calculate carbon emissions from factories to distribution centers
fd_carbon = 0
for f in range(num_factories):
for d in range(num_distribution_centers):
fd_carbon += factory_dc_carbon[f, d] * np.sum(x[:, f, d])

return sf_carbon + fd_carbon

def objective_function(x, weight_cost=0.5, weight_carbon=0.5):
"""
Multi-objective function to minimize both cost and carbon emissions.
The weights determine the importance of each objective.
"""
# Normalize objectives to similar scales
cost = supply_chain_cost(x) / 100000
carbon = supply_chain_carbon(x) / 50000

return weight_cost * cost + weight_carbon * carbon

def constraint_demand(x):
"""Ensure demand at each distribution center is met."""
x = x.reshape((num_suppliers, num_factories, num_distribution_centers))
result = []

for d in range(num_distribution_centers):
# Sum of flow to distribution center d should equal its demand
flow_to_dc = np.sum(x[:, :, d])
result.append(flow_to_dc - dc_demand[d])

return np.array(result)

def constraint_factory_capacity(x):
"""Ensure factory capacity constraints are not violated."""
x = x.reshape((num_suppliers, num_factories, num_distribution_centers))
result = []

for f in range(num_factories):
# Sum of flow through factory f should not exceed its capacity
flow_through_factory = np.sum(x[:, f, :])
result.append(factory_capacity[f] - flow_through_factory)

return np.array(result)

def constraint_supplier_capacity(x):
"""Ensure supplier capacity constraints are not violated."""
x = x.reshape((num_suppliers, num_factories, num_distribution_centers))
result = []

for s in range(num_suppliers):
# Sum of flow from supplier s should not exceed its capacity
flow_from_supplier = np.sum(x[s, :, :])
result.append(supplier_capacity[s] - flow_from_supplier)

return np.array(result)

# Run the optimization for different weight combinations
weights = np.linspace(0, 1, 11) # 0, 0.1, 0.2, ..., 1.0
results = []

for weight_cost in weights:
weight_carbon = 1 - weight_cost

# Initial solution: equal distribution
initial_flow = np.ones((num_suppliers, num_factories, num_distribution_centers)) * 50
initial_flow = initial_flow.flatten()

# Define constraints
constraints = [
{'type': 'eq', 'fun': constraint_demand},
{'type': 'ineq', 'fun': constraint_factory_capacity},
{'type': 'ineq', 'fun': constraint_supplier_capacity}
]

# Bounds: flow must be non-negative
bounds = [(0, None) for _ in range(len(initial_flow))]

# Solve the optimization problem
solution = minimize(
fun=lambda x: objective_function(x, weight_cost, weight_carbon),
x0=initial_flow,
method='SLSQP',
bounds=bounds,
constraints=constraints,
options={'maxiter': 1000}
)

if solution.success:
optimal_flow = solution.x.reshape((num_suppliers, num_factories, num_distribution_centers))
cost = supply_chain_cost(solution.x)
carbon = supply_chain_carbon(solution.x)
results.append({
'weight_cost': weight_cost,
'weight_carbon': weight_carbon,
'total_cost': cost,
'total_carbon': carbon,
'flow': optimal_flow
})
print(f"Weight cost: {weight_cost:.1f}, Weight carbon: {weight_carbon:.1f}, Cost: {cost:.2f}, Carbon: {carbon:.2f}")
else:
print(f"Optimization failed for weights: {weight_cost:.1f}, {weight_carbon:.1f}")

# Visualize the Pareto front
pareto_costs = [result['total_cost'] for result in results]
pareto_carbon = [result['total_carbon'] for result in results]

plt.figure(figsize=(10, 6))
plt.scatter(pareto_costs, pareto_carbon, c=weights, cmap='viridis', s=100)
cbar = plt.colorbar()
cbar.set_label('Weight on Cost Objective')
plt.xlabel('Total Cost ($)')
plt.ylabel('Total Carbon Emissions (kg CO₂)')
plt.title('Pareto Front: Trade-off between Cost and Carbon Emissions')
plt.grid(True, alpha=0.3)

# Add annotations for some key points
for i in [0, 5, 10]:
if i < len(results):
plt.annotate(
f"Cost weight: {results[i]['weight_cost']:.1f}",
(results[i]['total_cost'], results[i]['total_carbon']),
textcoords="offset points",
xytext=(0, 10),
ha='center'
)

plt.tight_layout()
plt.savefig('pareto_front.png', dpi=300)
plt.show()

# Visualize the optimal flow network for a selected solution (balanced weights)
# Let's use the middle solution (weight_cost = 0.5, weight_carbon = 0.5)
balanced_solution = results[5] # Index 5 corresponds to weights 0.5, 0.5
optimal_flow = balanced_solution['flow']

# Create a directed graph
G = nx.DiGraph()

# Add nodes
suppliers = [f"S{i+1}" for i in range(num_suppliers)]
factories = [f"F{i+1}" for i in range(num_factories)]
dcs = [f"DC{i+1}" for i in range(num_distribution_centers)]

# Add all nodes
for node in suppliers + factories + dcs:
G.add_node(node)

# Set node positions
pos = {}
# Suppliers on the left
for i, s in enumerate(suppliers):
pos[s] = (0, (num_suppliers-1)/2 - i)
# Factories in the middle
for i, f in enumerate(factories):
pos[f] = (1, (num_factories-1)/2 - i)
# Distribution centers on the right
for i, d in enumerate(dcs):
pos[d] = (2, (num_distribution_centers-1)/2 - i)

# Add edges with weights based on the optimal flow
edge_widths = []
edge_labels = {}

# Supplier to factory connections
for s in range(num_suppliers):
for f in range(num_factories):
flow_amount = np.sum(optimal_flow[s, f, :])
if flow_amount > 0:
G.add_edge(suppliers[s], factories[f], weight=flow_amount)
edge_widths.append(flow_amount / 100) # Scale down for visualization
edge_labels[(suppliers[s], factories[f])] = f"{int(flow_amount)}"

# Factory to distribution center connections
for f in range(num_factories):
for d in range(num_distribution_centers):
flow_amount = np.sum(optimal_flow[:, f, d])
if flow_amount > 0:
G.add_edge(factories[f], dcs[d], weight=flow_amount)
edge_widths.append(flow_amount / 100) # Scale down for visualization
edge_labels[(factories[f], dcs[d])] = f"{int(flow_amount)}"

# Plot the graph
plt.figure(figsize=(12, 8))
node_colors = ['skyblue'] * num_suppliers + ['lightgreen'] * num_factories + ['lightcoral'] * num_distribution_centers
node_sizes = [1500] * (num_suppliers + num_factories + num_distribution_centers)

nx.draw_networkx(
G, pos,
node_color=node_colors,
node_size=node_sizes,
with_labels=True,
font_size=10,
font_weight='bold',
arrowsize=15,
width=edge_widths,
edge_color='gray',
alpha=0.7
)

nx.draw_networkx_edge_labels(
G, pos,
edge_labels=edge_labels,
font_size=8
)

plt.title(f"Optimal Supply Chain Flow (Cost Weight: {balanced_solution['weight_cost']}, Carbon Weight: {balanced_solution['weight_carbon']})")
plt.axis('off')
plt.tight_layout()
plt.savefig('optimal_flow_network.png', dpi=300)
plt.show()

# Create a heatmap visualization of the flow
fig, axes = plt.subplots(1, 2, figsize=(15, 6))

# Aggregate flows for Supplier to Factory
sf_flow = np.zeros((num_suppliers, num_factories))
for s in range(num_suppliers):
for f in range(num_factories):
sf_flow[s, f] = np.sum(optimal_flow[s, f, :])

# Aggregate flows for Factory to DC
fd_flow = np.zeros((num_factories, num_distribution_centers))
for f in range(num_factories):
for d in range(num_distribution_centers):
fd_flow[f, d] = np.sum(optimal_flow[:, f, d])

# Create custom color map (white to green)
green_cmap = LinearSegmentedColormap.from_list('GreenMap', ['white', 'forestgreen'])

# Plot heatmap for Supplier to Factory flows
sns.heatmap(sf_flow, annot=True, fmt='.0f', cmap=green_cmap, ax=axes[0],
xticklabels=[f'Factory {i+1}' for i in range(num_factories)],
yticklabels=[f'Supplier {i+1}' for i in range(num_suppliers)])
axes[0].set_title('Material Flow: Suppliers to Factories')

# Plot heatmap for Factory to DC flows
sns.heatmap(fd_flow, annot=True, fmt='.0f', cmap=green_cmap, ax=axes[1],
xticklabels=[f'DC {i+1}' for i in range(num_distribution_centers)],
yticklabels=[f'Factory {i+1}' for i in range(num_factories)])
axes[1].set_title('Product Flow: Factories to Distribution Centers')

plt.tight_layout()
plt.savefig('flow_heatmaps.png', dpi=300)
plt.show()

# Visualize the trade-off details
costs = [result['total_cost'] for result in results]
carbon = [result['total_carbon'] for result in results]
weights_cost = [result['weight_cost'] for result in results]

fig, ax1 = plt.subplots(figsize=(10, 6))

color = 'tab:blue'
ax1.set_xlabel('Cost Weight')
ax1.set_ylabel('Total Cost ($)', color=color)
line1 = ax1.plot(weights_cost, costs, 'o-', color=color, label='Total Cost')
ax1.tick_params(axis='y', labelcolor=color)
ax1.grid(True, alpha=0.3)

ax2 = ax1.twinx()
color = 'tab:green'
ax2.set_ylabel('Total Carbon Emissions (kg CO₂)', color=color)
line2 = ax2.plot(weights_cost, carbon, 's-', color=color, label='Carbon Emissions')
ax2.tick_params(axis='y', labelcolor=color)

# Add combined legend
lines = line1 + line2
labels = [l.get_label() for l in lines]
ax1.legend(lines, labels, loc='upper center')

plt.title('Effect of Cost Weight on Total Cost and Carbon Emissions')
plt.tight_layout()
plt.savefig('weight_effects.png', dpi=300)
plt.show()

# Let's calculate and display the percentage changes
min_cost = min(costs)
max_cost = max(costs)
min_carbon = min(carbon)
max_carbon = max(carbon)

cost_range = max_cost - min_cost
carbon_range = max_carbon - min_carbon

# Create a table of results
results_df = pd.DataFrame({
'Cost Weight': weights_cost,
'Carbon Weight': [1-w for w in weights_cost],
'Total Cost': costs,
'Total Carbon': carbon,
'Cost % of Max': [(c - min_cost) / cost_range * 100 for c in costs],
'Carbon % of Max': [(c - min_carbon) / carbon_range * 100 for c in carbon]
})

# Display the formatted table
pd.set_option('display.float_format', '{:.2f}'.format)
print("\nTrade-off Analysis Table:")
print(results_df)

# Calculate the mathematical model explicitly
# Let's express the mathematical optimization model in LaTeX format
print("\nMathematical Model:")
print("Minimize: $\\alpha \\sum_{s=1}^{S} \\sum_{f=1}^{F} \\sum_{d=1}^{D} (c_{sf} + c_{fd}) x_{sfd} + (1-\\alpha) \\sum_{s=1}^{S} \\sum_{f=1}^{F} \\sum_{d=1}^{D} (e_{sf} + e_{fd}) x_{sfd}$")
print("\nSubject to:")
print("$\\sum_{s=1}^{S} \\sum_{f=1}^{F} x_{sfd} = d_d, \\forall d \\in D$ (Demand Constraints)")
print("$\\sum_{s=1}^{S} \\sum_{d=1}^{D} x_{sfd} \\leq cap_f, \\forall f \\in F$ (Factory Capacity Constraints)")
print("$\\sum_{f=1}^{F} \\sum_{d=1}^{D} x_{sfd} \\leq sup_s, \\forall s \\in S$ (Supplier Capacity Constraints)")
print("$x_{sfd} \\geq 0, \\forall s \\in S, f \\in F, d \\in D$ (Non-negativity Constraints)")

Explaining the Green Supply Chain Optimization Model

Our Python code implements a multi-objective optimization model for a green supply chain. Let’s break down the key components:

1. Problem Definition

We’re modeling a supply chain with:

  • 3 suppliers
  • 2 manufacturing facilities
  • 3 distribution centers

Each route has associated costs and carbon emissions.
The goal is to find the optimal flow of materials that minimizes both while meeting all constraints.

2. Mathematical Model

The optimization problem can be mathematically expressed as:

$$\text{Minimize: } \alpha \sum_{s=1}^{S} \sum_{f=1}^{F} \sum_{d=1}^{D} (c_{sf} + c_{fd}) x_{sfd} + (1-\alpha) \sum_{s=1}^{S} \sum_{f=1}^{F} \sum_{d=1}^{D} (e_{sf} + e_{fd}) x_{sfd}$$

Subject to:

  • Demand constraints: $\sum_{s=1}^{S} \sum_{f=1}^{F} x_{sfd} = d_d, \forall d \in D$
  • Factory capacity: $\sum_{s=1}^{S} \sum_{d=1}^{D} x_{sfd} \leq cap_f, \forall f \in F$
  • Supplier capacity: $\sum_{f=1}^{F} \sum_{d=1}^{D} x_{sfd} \leq sup_s, \forall s \in S$
  • Non-negativity: $x_{sfd} \geq 0, \forall s \in S, f \in F, d \in D$

Where:

  • $\alpha$ is the weight given to cost (vs. carbon)
  • $c_{sf}$ is the transportation cost from supplier s to factory f
  • $c_{fd}$ is the transportation cost from factory f to distribution center d
  • $e_{sf}$ and $e_{fd}$ are corresponding carbon emissions
  • $x_{sfd}$ is the flow quantity from supplier s through factory f to distribution center d

3. Key Code Functions

The code implements several important functions:

  • supply_chain_cost(): Calculates the total cost
  • supply_chain_carbon(): Calculates total carbon emissions
  • objective_function(): Combines cost and carbon objectives with weights
  • Constraint functions ensure:
    • All demands are met
    • Factory capacities aren’t exceeded
    • Supplier capacities aren’t exceeded

4. Multi-Objective Approach

We use a weighted sum approach, where:

  • weight_cost: Importance of cost minimization (0-1)
  • weight_carbon: Importance of carbon minimization (0-1)

By varying these weights (0.0, 0.1, 0.2, …, 1.0), we generate different solutions along the Pareto front.

Analyzing the Results

Let’s examine the visualizations produced by our code:

The Pareto Front

Weight cost: 0.0, Weight carbon: 1.0, Cost: 308438.41, Carbon: 117146.14
Weight cost: 0.1, Weight carbon: 0.9, Cost: 307784.46, Carbon: 117488.92
Weight cost: 0.2, Weight carbon: 0.8, Cost: 306437.63, Carbon: 118363.02
Weight cost: 0.3, Weight carbon: 0.7, Cost: 305915.35, Carbon: 118611.49
Weight cost: 0.4, Weight carbon: 0.6, Cost: 305066.62, Carbon: 118339.98
Weight cost: 0.5, Weight carbon: 0.5, Cost: 298500.00, Carbon: 151749.93
Weight cost: 0.6, Weight carbon: 0.4, Cost: 298499.99, Carbon: 151749.95
Weight cost: 0.7, Weight carbon: 0.3, Cost: 298499.98, Carbon: 151749.96
Weight cost: 0.8, Weight carbon: 0.2, Cost: 298499.97, Carbon: 151749.98
Weight cost: 0.9, Weight carbon: 0.1, Cost: 298499.97, Carbon: 151750.00
Weight cost: 1.0, Weight carbon: 0.0, Cost: 298499.96, Carbon: 151750.02

This graph shows the trade-off between cost and carbon emissions.
Each point represents a different solution with different weights.
Points on the lower left would be ideal (low cost, low emissions), but this perfect scenario is usually unattainable.

Key observations:

  • The cost-optimized solution (weight_cost = 1.0) gives the lowest cost but highest emissions
  • The carbon-optimized solution (weight_cost = 0.0) gives the lowest emissions but highest cost
  • Middle points offer different compromises between environmental and economic objectives

The Optimal Flow Network

This visualization shows the material flow for the balanced solution (weight_cost = 0.5, weight_carbon = 0.5). The numbers on the edges indicate the flow quantities.

We can see:

  • Supplier 1 primarily serves Factory 2
  • Supplier 2 primarily serves Factory 1
  • Supplier 3 serves both factories
  • Factory 1 handles Distribution Centers 1 and 3
  • Factory 2 handles all Distribution Centers with emphasis on DC 2

Flow Heatmaps

These heatmaps provide a clearer view of the flow quantities:

  • The left heatmap shows flows from suppliers to factories
  • The right heatmap shows flows from factories to distribution centers

The deeper green colors indicate larger flows.

Weight Effects on Objectives

Trade-off Analysis Table:
    Cost Weight  Carbon Weight  Total Cost  Total Carbon  Cost % of Max  \
0          0.00           1.00   308438.41     117146.14         100.00   
1          0.10           0.90   307784.46     117488.92          93.42   
2          0.20           0.80   306437.63     118363.02          79.87   
3          0.30           0.70   305915.35     118611.49          74.61   
4          0.40           0.60   305066.62     118339.98          66.07   
5          0.50           0.50   298500.00     151749.93           0.00   
6          0.60           0.40   298499.99     151749.95           0.00   
7          0.70           0.30   298499.98     151749.96           0.00   
8          0.80           0.20   298499.97     151749.98           0.00   
9          0.90           0.10   298499.97     151750.00           0.00   
10         1.00           0.00   298499.96     151750.02           0.00   

    Carbon % of Max  
0              0.00  
1              0.99  
2              3.52  
3              4.23  
4              3.45  
5            100.00  
6            100.00  
7            100.00  
8            100.00  
9            100.00  
10           100.00  

Mathematical Model:
Minimize: $\alpha \sum_{s=1}^{S} \sum_{f=1}^{F} \sum_{d=1}^{D} (c_{sf} + c_{fd}) x_{sfd} + (1-\alpha) \sum_{s=1}^{S} \sum_{f=1}^{F} \sum_{d=1}^{D} (e_{sf} + e_{fd}) x_{sfd}$

Subject to:
$\sum_{s=1}^{S} \sum_{f=1}^{F} x_{sfd} = d_d, \forall d \in D$ (Demand Constraints)
$\sum_{s=1}^{S} \sum_{d=1}^{D} x_{sfd} \leq cap_f, \forall f \in F$ (Factory Capacity Constraints)
$\sum_{f=1}^{F} \sum_{d=1}^{D} x_{sfd} \leq sup_s, \forall s \in S$ (Supplier Capacity Constraints)
$x_{sfd} \geq 0, \forall s \in S, f \in F, d \in D$ (Non-negativity Constraints)

This graph shows how changing the weight on cost (vs. carbon) affects both objectives:

  • As we increase the weight on cost, total cost decreases
  • As we increase the weight on cost, carbon emissions increase
  • The relationship isn’t linear, showing the complex nature of the trade-offs

Business Insights from the Results

  1. No “Perfect” Solution: There’s always a trade-off between economic and environmental objectives. Businesses must decide which balance aligns with their sustainability goals and economic constraints.

  2. Marginal Costs of Green Initiatives: The varying steepness of the Pareto front shows that some environmental improvements can be made with minimal cost increases, while others become increasingly expensive.

  3. Strategic Decision Making: Companies can use this approach to:

    • Quantify the cost of reducing carbon emissions
    • Set realistic sustainability targets
    • Make data-driven supply chain decisions
  4. Policy Implications: If carbon taxes or incentives are implemented, the optimal solution will shift. This model can help analyze the impact of such policies.

Implementation Considerations

In real-world scenarios, you might want to expand this model to include:

  1. Multiple time periods for planning
  2. Uncertainty in demand, costs, or emissions
  3. More detailed transportation modes with different emissions profiles
  4. Facility location decisions
  5. Inventory holding costs and carbon impacts

Conclusion

Our Python model demonstrates how companies can systematically approach the challenge of designing sustainable supply chains that balance environmental and economic objectives.
By generating the Pareto front of solutions, decision-makers can visualize the trade-offs and choose a solution that aligns with their sustainability strategy.

The multi-objective approach is flexible and can be adapted to different industries and supply chain configurations.
As sustainability becomes increasingly important, these types of models will be essential tools for supply chain managers and sustainability officers.


Note: This model is a simplified example.
Real-world implementations would require more detailed data and possibly additional constraints specific to the industry and business context.

Optimizing Power Supply and Demand Balance in Smart Grid Operations

Posted on April 17, 2025 by SmartGridEnthusiast

Smart grids represent the future of electricity distribution, offering intelligent ways to balance power supply and demand.
Today, I’ll walk you through a practical example of how we can optimize this balance using Python and mathematical programming.

Let’s tackle a realistic scenario: managing multiple power sources (traditional and renewable) while meeting consumer demand throughout the day, all while minimizing costs and ensuring stability.

The Smart Grid Optimization Problem

In our example, we’ll model a smart grid with:

  • Multiple power generators (coal, gas, solar, wind)
  • Varying demand throughout a 24-hour period
  • Different production costs and constraints
  • Energy storage capabilities

The mathematical formulation of our problem involves minimizing the total cost while satisfying all constraints:

$$ \min \sum_{t=1}^{T} \sum_{g=1}^{G} C_g \cdot P_{g,t} + \sum_{t=1}^{T} C_{storage} \cdot |S_t - S_{t-1}| $$

Subject to:
$$ \sum_{g=1}^{G} P_{g,t} + S_{t-1} - S_t = D_t \quad \forall t \in T $$
$$ P_{g,t}^{min} \leq P_{g,t} \leq P_{g,t}^{max} \quad \forall g \in G, t \in T $$
$$ 0 \leq S_t \leq S^{max} \quad \forall t \in T $$

Where:

  • $P_{g,t}$ is the power output of generator $g$ at time $t$
  • $C_g$ is the cost coefficient for generator $g$
  • $S_t$ is the stored energy at time $t$
  • $D_t$ is the demand at time $t$

Let’s implement this model using Python with the PuLP library for optimization:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import pulp
from matplotlib.ticker import MaxNLocator
import seaborn as sns

# Set seed for reproducibility
np.random.seed(42)

# Define the problem parameters
num_hours = 24
num_generators = 4

# Generator types and their properties
generator_types = ['Coal', 'Natural Gas', 'Solar', 'Wind']
generator_costs = {'Coal': 100, 'Natural Gas': 80, 'Solar': 5, 'Wind': 10} # Cost per MWh
generator_min_output = {'Coal': 50, 'Natural Gas': 20, 'Solar': 0, 'Wind': 0} # Min output in MW
generator_max_output = {'Coal': 200, 'Natural Gas': 150, 'Solar': 100, 'Wind': 120} # Max output in MW

# Set up time-dependent availability for renewables (solar and wind)
solar_availability = np.zeros(num_hours)
wind_availability = np.zeros(num_hours)

# Solar availability follows a bell curve (daylight hours)
for t in range(num_hours):
if 6 <= t < 18: # Daylight hours (6 AM to 6 PM)
solar_availability[t] = 0.8 * np.sin(np.pi * (t - 6) / 12) + 0.1
else:
solar_availability[t] = 0.1 # Minimal solar at night

# Wind availability has some variability throughout the day
wind_availability = 0.4 + 0.2 * np.sin(np.pi * np.arange(num_hours) / 12) + 0.1 * np.random.rand(num_hours)

# Demand profile throughout the day (in MW)
demand = 200 + 100 * np.sin(np.pi * (np.arange(num_hours) - 4) / 12) + 50 * np.sin(np.pi * (np.arange(num_hours) - 8) / 8)
demand = demand.astype(int)

# Storage parameters
storage_capacity = 150 # Maximum storage capacity in MWh
storage_cost = 15 # Cost per MWh of charging/discharging
initial_storage = 50 # Initial storage level in MWh

# Create the optimization model
model = pulp.LpProblem("Smart_Grid_Optimization", pulp.LpMinimize)

# Decision variables
# Power output for each generator at each hour
power_output = {(g, t): pulp.LpVariable(f"Power_{g}_{t}",
lowBound=generator_min_output[g],
upBound=generator_max_output[g] * (1 if g not in ['Solar', 'Wind'] else
(solar_availability[t] if g == 'Solar' else wind_availability[t])),
cat='Continuous')
for g in generator_types for t in range(num_hours)}

# Storage level at each hour
storage = {t: pulp.LpVariable(f"Storage_{t}", lowBound=0, upBound=storage_capacity, cat='Continuous')
for t in range(num_hours)}

# Variables to track charging and discharging (for cost calculation)
charging = {t: pulp.LpVariable(f"Charging_{t}", lowBound=0, cat='Continuous')
for t in range(num_hours)}
discharging = {t: pulp.LpVariable(f"Discharging_{t}", lowBound=0, cat='Continuous')
for t in range(num_hours)}

# Objective function: minimize total cost
model += pulp.lpSum([generator_costs[g] * power_output[g, t] for g in generator_types for t in range(num_hours)]) + \
pulp.lpSum([storage_cost * (charging[t] + discharging[t]) for t in range(num_hours)])

# Constraints
# Initial storage level
model += storage[0] == initial_storage

# Storage dynamics
for t in range(1, num_hours):
model += storage[t] - storage[t-1] == charging[t] - discharging[t]

# Power balance: supply = demand at each hour
for t in range(num_hours):
model += pulp.lpSum([power_output[g, t] for g in generator_types]) + discharging[t] - charging[t] == demand[t]

# Solve the model
model.solve(pulp.PULP_CBC_CMD(msg=False))

print(f"Status: {pulp.LpStatus[model.status]}")
print(f"Total Cost: ${pulp.value(model.objective):.2f}")

# Extract results
results = pd.DataFrame(index=range(num_hours), columns=generator_types + ['Storage', 'Demand'])

for t in range(num_hours):
for g in generator_types:
results.loc[t, g] = power_output[g, t].value()
results.loc[t, 'Storage'] = storage[t].value()
results.loc[t, 'Demand'] = demand[t]

# Plot the results
plt.figure(figsize=(15, 10))

# Plot 1: Generator outputs and demand
ax1 = plt.subplot(2, 1, 1)
bottom = np.zeros(num_hours)

for gen in generator_types:
plt.bar(range(num_hours), results[gen], bottom=bottom, label=gen, alpha=0.7)
bottom += results[gen].values

plt.plot(range(num_hours), results['Demand'], 'r-', linewidth=2, label='Demand')
plt.title('Hourly Power Generation by Source vs Demand', fontsize=14)
plt.xlabel('Hour of Day')
plt.ylabel('Power (MW)')
plt.legend()
plt.xticks(range(0, num_hours, 2))
plt.grid(True, alpha=0.3)

# Plot 2: Storage level
ax2 = plt.subplot(2, 1, 2)
plt.plot(range(num_hours), results['Storage'], 'g-', linewidth=2, marker='o')
plt.fill_between(range(num_hours), results['Storage'], alpha=0.3, color='green')
plt.title('Energy Storage Level Over Time', fontsize=14)
plt.xlabel('Hour of Day')
plt.ylabel('Storage Level (MWh)')
plt.xticks(range(0, num_hours, 2))
plt.grid(True, alpha=0.3)
plt.tight_layout()

# Additional plot: Cost breakdown
costs = pd.DataFrame(index=generator_types + ['Storage'])
for g in generator_types:
costs.loc[g, 'Cost'] = sum(generator_costs[g] * power_output[g, t].value() for t in range(num_hours))

storage_total_cost = 0
for t in range(num_hours):
if t > 0:
storage_total_cost += storage_cost * abs(storage[t].value() - storage[t-1].value())

costs.loc['Storage', 'Cost'] = storage_total_cost

plt.figure(figsize=(10, 6))
sns.barplot(x=costs.index, y='Cost', data=costs)
plt.title('Total Cost by Energy Source', fontsize=14)
plt.xlabel('Source')
plt.ylabel('Cost ($)')
plt.grid(True, alpha=0.3)
plt.tight_layout()

# Export results to CSV
results.to_csv('smart_grid_optimization_results.csv')

plt.show()

# Print hourly breakdown
print("\nHourly Power Generation and Storage:")
print(results.round(2))

# Calculate some key metrics
total_renewable = results['Solar'].sum() + results['Wind'].sum()
total_conventional = results['Coal'].sum() + results['Natural Gas'].sum()
total_generation = total_renewable + total_conventional

print(f"\nPercentage of Renewable Energy: {100 * total_renewable / total_generation:.2f}%")
print(f"Maximum Storage Used: {results['Storage'].max():.2f} MWh")
print(f"Storage Utilization: {100 * results['Storage'].max() / storage_capacity:.2f}%")

Code Explanation

Let’s break down this implementation step by step:

1. Problem Setup

First, we define our smart grid parameters:

  • Four generator types (Coal, Natural Gas, Solar, Wind)
  • Cost structures for each generator
  • Operational constraints (min/max output)
  • Time-dependent availability for renewables
  • Demand curve throughout the day
  • Energy storage capabilities

The solar availability follows a realistic daily pattern with peak availability during mid-day hours, while wind power has some natural variability.
Demand follows a common pattern with morning and evening peaks.

2. Mathematical Model Implementation Using PuLP

We use the PuLP library to define our linear programming model:

  • Decision Variables:

    • power_output[g,t]: How much power each generator produces each hour
    • storage[t]: Battery storage level at each hour
    • charging[t] and discharging[t]: Energy flow in/out of storage
  • Objective Function:
    Minimize the total cost of generation plus storage operations.

  • Constraints:

    • Power balance at each hour (supply = demand)
    • Generator output limitations
    • Storage capacity constraints
    • Storage dynamics (tracking energy flow)

3. Solving the Model

The PuLP solver (CBC) finds the optimal solution that minimizes costs while meeting all constraints.
This shows how different power sources should be dispatched throughout the day.

4. Visualization and Analysis

We generate comprehensive visualizations to understand the solution:

  • A stacked bar chart showing how each generator contributes hourly
  • A line plot tracking storage levels
  • A cost breakdown by energy source

Results Analysis

Let’s examine the graphs to understand the optimal solution:

Status: Optimal
Total Cost: $251791.35


Hourly Power Generation and Storage:
    Coal  Natural Gas  Solar   Wind  Storage  Demand
0   50.0        20.00  10.00  33.00    50.00     113
1   50.0        20.00  10.00  65.62    85.62     110
2   50.0        20.00  10.00  68.78   120.40     114
3   50.0        20.00  10.00  72.15   145.56     127
4   50.0        20.00  10.00  70.66   146.22     150
5   50.0        20.00  10.00  73.05   120.27     179
6   50.0        20.00  10.00  72.70    58.97     214
7   50.0        29.75  30.71  81.58     0.00     251
8   50.0       110.00  50.00  76.00     0.00     286
9   50.0       124.96  66.57  73.47     0.00     315
10  50.0       145.47  79.28  60.25     0.00     335
11  50.0       138.88  87.27  65.85     0.00     342
12  50.0       138.01  90.00  57.99     0.00     336
13  50.0       134.39  87.27  44.34     0.00     316
14  50.0       117.54  79.28  38.18     0.00     285
15  50.0        95.20  66.57  33.23     0.00     245
16  50.0        69.13  50.00  30.87     0.00     200
17  50.0        42.18  30.71  31.11     0.00     154
18  50.0        24.82  10.00  29.18     0.00     114
19  50.0        20.00  10.00   3.00     0.00      83
20  50.0        20.00   0.00   0.00     7.00      63
21  50.0        20.00   0.00   0.00    20.00      57
22  50.0        20.00   0.00   0.00    26.00      64
23  50.0        20.00  10.00   4.00    26.00      84

Percentage of Renewable Energy: 42.16%
Maximum Storage Used: 146.22 MWh
Storage Utilization: 97.48%

Generator Dispatch Pattern

Looking at the hourly power generation chart, we can observe several key insights:

  1. Base Load Operation: Coal plants, with their higher minimum output requirements but high operational costs, are used primarily as base load during peak demand hours.

  2. Mid-merit Operation: Natural gas generators, with more flexibility and moderate costs, are ramped up and down to follow the demand curve.

  3. Renewable Integration: Solar energy is maximized during daylight hours, while wind power is used whenever available, as they have the lowest operational costs.

  4. Peak Management: The combination of all sources plus storage is optimized to meet peak demand periods in the morning and evening.

Storage Utilization

The storage level chart shows:

  1. Charge/Discharge Cycles: The storage system charges during low-demand periods (especially when renewable generation is high) and discharges during peak demand periods.

  2. Peak Shaving: The storage helps “shave” demand peaks by providing additional power, reducing the need for expensive peaking generators.

  3. Renewable Smoothing: Storage helps balance the intermittent nature of renewable sources, storing excess generation for later use.

Cost Distribution

The cost breakdown reveals:

  1. Generation Mix Economics: Despite their lower capacity factors, renewables contribute significantly to cost savings.

  2. Storage Economics: The cost of storage operations is offset by the system’s ability to shift energy from low-cost generation periods to high-demand periods.

Practical Applications

This optimization approach can be applied in several real-world scenarios:

  1. Microgrid Management: Isolated communities can optimize their local generation mix.

  2. Utility-Scale Operations: Power companies can optimize their generation fleet.

  3. Renewable Integration Planning: Grid operators can determine optimal storage capacity to accommodate increasing renewable penetration.

  4. Demand Response Programs: Utilities can quantify the value of flexible demand.

Conclusion

Our Python model demonstrates how mathematical optimization can effectively balance power supply and demand in a smart grid environment.
By accounting for the specific characteristics of different generation sources, demand patterns, and storage capabilities, grid operators can minimize costs while ensuring reliable electricity supply.

As renewable energy sources continue to grow in importance, these optimization techniques will become increasingly valuable for managing the complex dynamics of modern power systems.

Real Options Theory in Investment Decision Making

A Practical Example

Today I’m going to walk through a fascinating application of real options theory to investment decision making.
Unlike traditional NPV analysis, real options theory recognizes the value of flexibility in business decisions - the ability to delay, expand, contract or abandon projects as new information becomes available.

Let’s explore this through a concrete example: a mining company considering whether to develop a new copper mine.

The Investment Scenario

Our mining company has identified a potential copper deposit.
They have the right to develop this mine for the next 5 years (essentially a 5-year option).
The initial investment required is $100 million.
Current analysis suggests the present value of future cash flows would be $90 million, meaning a traditional NPV calculation would reject this project.

But what if copper prices change? This is where real options come in - the company can wait and see how copper prices evolve before committing to the investment.

Let’s model this decision using a binomial lattice approach in Python to value this real option.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
import pandas as pd
import seaborn as sns
from scipy.stats import norm

# Parameters
S0 = 90 # Current PV of expected cash flows ($ million)
K = 100 # Investment cost ($ million)
T = 5 # Time to expiration (years)
r = 0.05 # Risk-free rate
sigma = 0.3 # Volatility of the underlying asset

# Number of steps in the binomial tree
n_steps = 5

# Calculate up and down factors
dt = T / n_steps
u = np.exp(sigma * np.sqrt(dt)) # Up factor
d = 1 / u # Down factor
p = (np.exp(r * dt) - d) / (u - d) # Risk-neutral probability

# Function to create the underlying asset price tree
def create_asset_tree(S0, u, d, n_steps):
tree = np.zeros((n_steps + 1, n_steps + 1))
for i in range(n_steps + 1):
for j in range(i + 1):
tree[j, i] = S0 * (u ** (i - j)) * (d ** j)
return tree

# Function to calculate the option value tree
def calculate_option_tree(asset_tree, K, r, dt, p, n_steps):
option_tree = np.zeros((n_steps + 1, n_steps + 1))

# Terminal payoffs (at expiration)
for i in range(n_steps + 1):
option_tree[i, n_steps] = max(0, asset_tree[i, n_steps] - K)

# Backward induction
for i in range(n_steps - 1, -1, -1):
for j in range(i + 1):
# Expected value of continuation
continuation_value = np.exp(-r * dt) * (p * option_tree[j, i + 1] + (1 - p) * option_tree[j + 1, i + 1])
# Immediate exercise value
exercise_value = max(0, asset_tree[j, i] - K)
# Option value is maximum of continuation and exercise
option_tree[j, i] = max(continuation_value, exercise_value)

return option_tree

# Create the asset price tree
asset_tree = create_asset_tree(S0, u, d, n_steps)

# Calculate the option value tree
option_tree = calculate_option_tree(asset_tree, K, r, dt, p, n_steps)

# Print the option value (value of flexibility)
real_option_value = option_tree[0, 0]
print(f"Traditional NPV: ${S0 - K} million")
print(f"Real Option Value: ${real_option_value:.2f} million")
print(f"Value of flexibility: ${real_option_value - (S0 - K):.2f} million")

# Create more detailed visualization
def plot_binomial_tree(tree, title, cmap=cm.viridis):
n_steps = tree.shape[1] - 1
fig, ax = plt.subplots(figsize=(12, 8))

# Create x and y coordinates for each node
x_coords = []
y_coords = []
values = []

for i in range(n_steps + 1):
for j in range(i + 1):
x_coords.append(i)
y_coords.append(j)
values.append(tree[j, i])

# Normalize values for color mapping
normalized_values = (values - np.min(values)) / (np.max(values) - np.min(values))
colors = cmap(normalized_values)

# Plot points
scatter = ax.scatter(x_coords, y_coords, c=values, cmap=cmap, s=100, edgecolors='black')

# Add value labels
for i, (x, y, val) in enumerate(zip(x_coords, y_coords, values)):
ax.annotate(f"{val:.1f}", (x, y), xytext=(0, 0), textcoords="offset points",
ha='center', va='center', color='white' if normalized_values[i] > 0.5 else 'black',
fontweight='bold')

# Set axis labels and title
ax.set_xlabel('Time Step')
ax.set_ylabel('Down Movements')
ax.set_title(title)

# Add colorbar
cbar = plt.colorbar(scatter)
cbar.set_label('Value ($ million)')

# Set ticks
ax.set_xticks(range(n_steps + 1))
ax.set_yticks(range(n_steps + 1))

# Adjust layout
plt.tight_layout()

return fig, ax

# Plot the asset price tree
fig1, ax1 = plot_binomial_tree(asset_tree, 'Project Value Tree ($ million)')
plt.show()

# Plot the option value tree
fig2, ax2 = plot_binomial_tree(option_tree, 'Real Option Value Tree ($ million)')
plt.show()

# Now let's also implement the Black-Scholes model for continuous-time valuation
def black_scholes_call(S, K, T, r, sigma):
d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)

# Calculate using Black-Scholes
bs_option_value = black_scholes_call(S0, K, T, r, sigma)
print(f"Black-Scholes Real Option Value: ${bs_option_value:.2f} million")

# Sensitivity analysis - varying volatility
volatilities = np.linspace(0.1, 0.6, 20)
bs_option_values = [black_scholes_call(S0, K, T, r, vol) for vol in volatilities]

plt.figure(figsize=(12, 6))
plt.plot(volatilities, bs_option_values, 'b-', linewidth=2)
plt.grid(True)
plt.xlabel('Volatility (σ)')
plt.ylabel('Real Option Value ($ million)')
plt.title('Sensitivity of Real Option Value to Volatility')
plt.show()

# Sensitivity analysis - varying underlying asset value
asset_values = np.linspace(50, 150, 20)
bs_option_values = [black_scholes_call(S, K, T, r, sigma) for S in asset_values]
npv_values = [S - K for S in asset_values]

plt.figure(figsize=(12, 6))
plt.plot(asset_values, bs_option_values, 'b-', linewidth=2, label='Real Option Value')
plt.plot(asset_values, npv_values, 'r--', linewidth=2, label='Traditional NPV')
plt.axhline(y=0, color='k', linestyle='-', alpha=0.3)
plt.axvline(x=K, color='k', linestyle='--', alpha=0.3, label='Investment Cost')
plt.grid(True)
plt.xlabel('Present Value of Cash Flows ($ million)')
plt.ylabel('Value ($ million)')
plt.title('Real Option Value vs. Traditional NPV')
plt.legend()
plt.show()

# Sensitivity to time to expiration
times = np.linspace(0.5, 10, 20)
bs_option_values = [black_scholes_call(S0, K, t, r, sigma) for t in times]

plt.figure(figsize=(12, 6))
plt.plot(times, bs_option_values, 'g-', linewidth=2)
plt.grid(True)
plt.xlabel('Time to Expiration (years)')
plt.ylabel('Real Option Value ($ million)')
plt.title('Impact of Option Expiration Time on Real Option Value')
plt.show()

# Create a 3D surface plot showing option value as a function of PV and volatility
PV_range = np.linspace(70, 130, 30)
vol_range = np.linspace(0.1, 0.5, 30)
PV_grid, vol_grid = np.meshgrid(PV_range, vol_range)
option_values = np.zeros_like(PV_grid)

for i in range(len(vol_range)):
for j in range(len(PV_range)):
option_values[i, j] = black_scholes_call(PV_range[j], K, T, r, vol_range[i])

fig = plt.figure(figsize=(14, 10))
ax = fig.add_subplot(111, projection='3d')
surf = ax.plot_surface(PV_grid, vol_grid, option_values, cmap=cm.viridis,
linewidth=0, antialiased=True)
ax.set_xlabel('Present Value of Cash Flows ($ million)')
ax.set_ylabel('Volatility (σ)')
ax.set_zlabel('Real Option Value ($ million)')
ax.set_title('3D Surface of Real Option Value')
fig.colorbar(surf, ax=ax, shrink=0.5, aspect=5)
plt.show()

# Create a heatmap
heatmap_data = pd.DataFrame(option_values, index=vol_range, columns=PV_range)
plt.figure(figsize=(14, 10))
sns.heatmap(heatmap_data, cmap='viridis', annot=False)
plt.xlabel('Present Value of Cash Flows ($ million)')
plt.ylabel('Volatility (σ)')
plt.title('Heatmap of Real Option Value')
plt.show()

Code Explanation

Let’s break down the key components of this code:

1. Parameter Setup

The code begins by defining our investment scenario parameters:

  • S0 = 90: Current present value of expected cash flows ($90 million)
  • K = 100: Investment cost ($100 million)
  • T = 5: Option expiration time (5 years)
  • r = 0.05: Risk-free rate (5%)
  • sigma = 0.3: Volatility of the underlying asset (30%)

2. Binomial Tree Implementation

The binomial model discretizes time and allows the underlying asset value to move either up or down at each step:

  • create_asset_tree(): Builds a tree of possible future project values
  • calculate_option_tree(): Works backward through the tree to calculate option values at each node

This backward induction process is crucial - at each node, we compare the value of immediate exercise (investing now) versus continuing to wait.

3. Black-Scholes Implementation

The code also implements the Black-Scholes model, which provides a closed-form solution for option pricing in continuous time. This gives us another valuation approach to verify our binomial model.

4. Visualization and Sensitivity Analysis

Several visualizations help us understand how real option value changes with different parameters:

  • Binomial trees for both project value and option value
  • Sensitivity to volatility
  • Comparison with traditional NPV
  • Impact of time to expiration
  • 3D surface plot showing option value as a function of PV and volatility
  • Heatmap visualization

Results and Analysis

Running the code reveals several important insights:

  1. Traditional NPV vs. Real Option Value:
    • Traditional NPV: -$10 million (suggesting rejection)
    • Real Option Value: ~$23.3 million (suggesting acceptance)
    • Value of flexibility: ~$33.3 million
Traditional NPV: $-10 million
Real Option Value: $29.10 million
Value of flexibility: $39.10 million
  1. Project Value Tree:
    The binomial tree shows how the underlying project value could evolve over time.
    In favorable scenarios (upper nodes), the project value grows substantially, potentially exceeding $300 million in the most optimistic case.

  1. Option Value Tree:
    The option value tree shows the value of the investment opportunity at each point in time and state.
    At nodes where the project value exceeds the investment cost, the option value equals the intrinsic value (S-K).
    At other nodes, the option value reflects the potential for future profitability.

Black-Scholes Real Option Value: $28.60 million
  1. Volatility Sensitivity:
    The sensitivity analysis shows that higher volatility increases option value - counterintuitively, more uncertainty can be valuable when you have flexibility.
    This is a key insight of real options theory.

  1. PV vs. NPV Comparison:
    The comparison chart shows that the real option value is always greater than or equal to the traditional NPV.
    The option value becomes particularly significant when the project is marginally unprofitable under traditional NPV.

  1. Time Value:
    Longer option expiration times increase the real option value, as they provide more opportunity for favorable price movements.

  1. 3D Surface and Heatmap:
    These visualizations showcase how real option value increases with both higher present value and higher volatility.


Business Implications

What does this mean for our mining company?

  1. Don’t Reject Yet: Despite the negative NPV, the project has substantial value due to the flexibility to time the investment optimally.

  2. Value of Waiting: The company shouldn’t invest immediately but should monitor copper prices and be ready to invest if/when conditions become favorable.

  3. Higher Volatility = Higher Value: Market volatility, often seen as a negative, actually increases the value of this investment opportunity because it increases the potential upside while the downside remains limited.

  4. Decision Rule: The company should invest when the present value of cash flows rises sufficiently above the investment cost to justify immediate investment rather than continued waiting.

Conclusion

Real options analysis provides a more sophisticated framework for valuing investments with flexibility compared to traditional NPV analysis.
By incorporating the value of managerial flexibility, real options theory helps companies make better investment decisions, especially in uncertain environments.

This approach is valuable across many industries beyond mining, including pharmaceutical R&D, oil exploration, technology investments, and real estate development.
Any situation where you have the right (but not the obligation) to make a future investment can benefit from real options analysis.

Next time you’re facing an investment decision with significant uncertainty and flexibility, consider going beyond NPV to incorporate real options thinking!

Dynamic Pricing in Revenue Management

An Aviation Case Study

Have you ever wondered why airline ticket prices fluctuate so dramatically?
One day you check a flight and it’s $$300$, the next day it’s $$450$, and a week later it’s $$600$.
This isn’t random - it’s the result of sophisticated dynamic pricing algorithms designed to maximize revenue based on demand forecasting.
Today, I’ll walk you through a practical example of how airlines might approach this problem using Python.

Understanding Dynamic Pricing in Revenue Management

Airlines have a limited inventory (seats) that perishes after a specific date (flight departure).
The challenge is to sell each seat at the optimal price to maximize total revenue.
This is a classic revenue management problem that balances:

  1. Selling too cheap too early (leaving money on the table)
  2. Pricing too high and risking empty seats (lost revenue)

Let’s build a model that simulates this decision-making process for an airline route.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import norm
from datetime import datetime, timedelta
import matplotlib.dates as mdates

# Set styling for plots
plt.style.use('ggplot')
sns.set_theme(style="whitegrid")

class AirlineRevenueSim:
def __init__(self, total_seats=100, days_to_departure=60,
base_price=300, demand_model='normal',
price_sensitivity=-0.01, random_seed=42):
"""
Initialize the airline revenue management simulation.

Parameters:
-----------
total_seats : int
Total capacity of the flight
days_to_departure : int
Number of days before departure to start selling
base_price : float
Starting price point for the ticket
demand_model : str
Type of demand model to use ('normal' for normally distributed demand)
price_sensitivity : float
How sensitive demand is to price changes (negative value)
random_seed : int
Seed for reproducibility
"""
np.random.seed(random_seed)

self.total_seats = total_seats
self.days_to_departure = days_to_departure
self.base_price = base_price
self.price_sensitivity = price_sensitivity

# Create a timeline from sales start to departure
self.timeline = pd.date_range(
end=datetime.now() + timedelta(days=5), # departure date
periods=days_to_departure,
freq='D'
)

# Generate the base demand curve - demand typically increases as departure approaches
if demand_model == 'normal':
# Base demand curve follows a normal distribution with peak near departure
mu = 0.8 * days_to_departure # Peak demand closer to departure
sigma = days_to_departure / 4
base_demand = norm.pdf(np.arange(days_to_departure), mu, sigma)
self.base_demand = base_demand / np.max(base_demand) * 30 # Scale to reasonable daily demand

# Initialize remaining inventory
self.remaining_seats = total_seats

# Storage for simulation results
self.results = {
'date': [],
'price': [],
'demand': [],
'sales': [],
'revenue': [],
'remaining_seats': []
}

def calculate_demand(self, price, day_index):
"""Calculate expected demand based on price and day."""
# Base demand for this day
base_demand = self.base_demand[day_index]

# Apply price sensitivity - higher prices reduce demand
price_effect = np.exp(self.price_sensitivity * (price - self.base_price))

# Add some randomness to simulate real-world variability
randomness = np.random.normal(1, 0.2)

# Calculate expected demand
expected_demand = base_demand * price_effect * randomness

# Ensure non-negative demand
return max(0, expected_demand)

def run_static_pricing(self):
"""Run simulation with static pricing (control case)."""
price = self.base_price

for day in range(self.days_to_departure):
# Calculate demand at current price
demand = self.calculate_demand(price, day)

# Actual sales are limited by remaining inventory
sales = min(demand, self.remaining_seats)

# Update remaining inventory
self.remaining_seats -= sales

# Record results
self.results['date'].append(self.timeline[day])
self.results['price'].append(price)
self.results['demand'].append(demand)
self.results['sales'].append(sales)
self.results['revenue'].append(sales * price)
self.results['remaining_seats'].append(self.remaining_seats)

# If we've sold out, stop simulation
if self.remaining_seats <= 0:
break

return pd.DataFrame(self.results)

def run_dynamic_pricing(self, load_factor_thresholds=[0.7, 0.5, 0.3],
price_adjustments=[1.0, 1.3, 1.6, 2.0]):
"""
Run simulation with dynamic pricing based on load factor.

Parameters:
-----------
load_factor_thresholds : list
Load factor levels at which to adjust prices
price_adjustments : list
Price multipliers to apply at each threshold (should be one more than thresholds)
"""
for day in range(self.days_to_departure):
# Calculate days remaining
days_remaining = self.days_to_departure - day - 1

# Calculate current load factor
load_factor = 1 - (self.remaining_seats / self.total_seats)

# Determine price based on target sell-out
target_load_factor = (day + 1) / self.days_to_departure

# Adjust price based on current load factor vs target
if days_remaining > 0: # Avoid division by zero
price_multiplier = 1.0

# If we're ahead of target, increase prices
if load_factor > target_load_factor:
# Find which threshold we've crossed
for i, threshold in enumerate(load_factor_thresholds):
if load_factor > threshold:
price_multiplier = price_adjustments[i]
break
else:
price_multiplier = price_adjustments[-1]

# Apply time-based adjustment: prices increase as departure approaches
time_factor = 1 + (0.5 * (1 - days_remaining / self.days_to_departure))

price = self.base_price * price_multiplier * time_factor
else:
# Last day - final price adjustment
if load_factor > 0.9:
price = self.base_price * 2.2 # Premium for last-minute travelers
else:
price = self.base_price * 0.9 # Discount to sell remaining seats

# Calculate demand at current price
demand = self.calculate_demand(price, day)

# Actual sales are limited by remaining inventory
sales = min(demand, self.remaining_seats)

# Update remaining inventory
self.remaining_seats -= sales

# Record results
self.results['date'].append(self.timeline[day])
self.results['price'].append(price)
self.results['demand'].append(demand)
self.results['sales'].append(sales)
self.results['revenue'].append(sales * price)
self.results['remaining_seats'].append(self.remaining_seats)

# If we've sold out, stop simulation
if self.remaining_seats <= 0:
break

return pd.DataFrame(self.results)

# Run simulations for both pricing strategies
np.random.seed(42) # For reproducibility

# Static pricing simulation
static_sim = AirlineRevenueSim(total_seats=150, days_to_departure=60, base_price=300)
static_results = static_sim.run_static_pricing()

# Reset and run dynamic pricing simulation
np.random.seed(42) # Use same seed for fair comparison
dynamic_sim = AirlineRevenueSim(total_seats=150, days_to_departure=60, base_price=300)
dynamic_results = dynamic_sim.run_dynamic_pricing()

# Create comparison visualizations
fig, axs = plt.subplots(2, 2, figsize=(16, 12))

# Plot 1: Price over time
axs[0, 0].plot(static_results['date'], static_results['price'], label='Static Pricing')
axs[0, 0].plot(dynamic_results['date'], dynamic_results['price'], label='Dynamic Pricing')
axs[0, 0].set_title('Ticket Price Over Time', fontsize=14)
axs[0, 0].set_xlabel('Date')
axs[0, 0].set_ylabel('Price ($)')
axs[0, 0].legend()
axs[0, 0].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

# Plot 2: Remaining seats over time
axs[0, 1].plot(static_results['date'], static_results['remaining_seats'], label='Static Pricing')
axs[0, 1].plot(dynamic_results['date'], dynamic_results['remaining_seats'], label='Dynamic Pricing')
axs[0, 1].set_title('Remaining Seats Over Time', fontsize=14)
axs[0, 1].set_xlabel('Date')
axs[0, 1].set_ylabel('Seats')
axs[0, 1].legend()
axs[0, 1].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

# Plot 3: Daily sales
axs[1, 0].bar(np.arange(len(static_results)) - 0.2, static_results['sales'], width=0.4, label='Static Pricing')
axs[1, 0].bar(np.arange(len(dynamic_results)) + 0.2, dynamic_results['sales'], width=0.4, label='Dynamic Pricing')
axs[1, 0].set_title('Daily Ticket Sales', fontsize=14)
axs[1, 0].set_xlabel('Days Before Departure')
axs[1, 0].set_ylabel('Number of Tickets Sold')
axs[1, 0].set_xticks(np.arange(0, len(static_results), 5))
axs[1, 0].set_xticklabels([f"{60-i}" for i in range(0, len(static_results), 5)])
axs[1, 0].legend()

# Plot 4: Cumulative revenue
static_cumulative = static_results['revenue'].cumsum()
dynamic_cumulative = dynamic_results['revenue'].cumsum()

axs[1, 1].plot(static_results['date'], static_cumulative, label='Static Pricing')
axs[1, 1].plot(dynamic_results['date'], dynamic_cumulative, label='Dynamic Pricing')
axs[1, 1].set_title('Cumulative Revenue', fontsize=14)
axs[1, 1].set_xlabel('Date')
axs[1, 1].set_ylabel('Revenue ($)')
axs[1, 1].legend()
axs[1, 1].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

plt.tight_layout()
plt.show()

# Print summary statistics
print("Static Pricing Summary:")
print(f"Total Revenue: ${static_results['revenue'].sum():.2f}")
print(f"Average Ticket Price: ${static_results['price'].mean():.2f}")
print(f"Tickets Sold: {static_results['sales'].sum():.0f}")
print(f"Remaining Seats: {static_results['remaining_seats'].iloc[-1]:.0f}")
print(f"Load Factor: {100 * (1 - static_results['remaining_seats'].iloc[-1] / 150):.1f}%")

print("\nDynamic Pricing Summary:")
print(f"Total Revenue: ${dynamic_results['revenue'].sum():.2f}")
print(f"Average Ticket Price: ${dynamic_results['price'].mean():.2f}")
print(f"Tickets Sold: {dynamic_results['sales'].sum():.0f}")
print(f"Remaining Seats: {dynamic_results['remaining_seats'].iloc[-1]:.0f}")
print(f"Load Factor: {100 * (1 - dynamic_results['remaining_seats'].iloc[-1] / 150):.1f}%")
print(f"Revenue Improvement: {100 * (dynamic_results['revenue'].sum() / static_results['revenue'].sum() - 1):.1f}%")

# Alternative model: Optimization-based approach
# Create a function to find the optimal price for each day based on expected demand

def optimize_prices(sim, remaining_days, remaining_seats):
"""
Find optimal price for each day to maximize expected revenue
using a simple grid search approach.
"""
prices = np.linspace(sim.base_price * 0.5, sim.base_price * 3, 50)
optimal_prices = []

for day in range(remaining_days):
day_index = sim.days_to_departure - remaining_days + day
expected_revenues = []

# For each price point, calculate expected revenue
for price in prices:
expected_demand = sim.calculate_demand(price, day_index)
expected_sales = min(expected_demand, remaining_seats)
expected_revenue = expected_sales * price
expected_revenues.append(expected_revenue)

# Find price that maximizes revenue
optimal_price = prices[np.argmax(expected_revenues)]
optimal_prices.append(optimal_price)

# Update remaining seats for next iteration
expected_demand = sim.calculate_demand(optimal_price, day_index)
expected_sales = min(expected_demand, remaining_seats)
remaining_seats -= expected_sales

if remaining_seats <= 0:
break

return optimal_prices

# Run optimization-based dynamic pricing
np.random.seed(42) # Reset seed
optim_sim = AirlineRevenueSim(total_seats=150, days_to_departure=60, base_price=300)

# Calculate optimal prices for the entire booking period
optimal_prices = optimize_prices(optim_sim, optim_sim.days_to_departure, optim_sim.total_seats)

# Now simulate sales with these optimal prices
optim_results = {
'date': [],
'price': [],
'demand': [],
'sales': [],
'revenue': [],
'remaining_seats': []
}

remaining_seats = optim_sim.total_seats

for day in range(len(optimal_prices)):
price = optimal_prices[day]
demand = optim_sim.calculate_demand(price, day)
sales = min(demand, remaining_seats)
remaining_seats -= sales

optim_results['date'].append(optim_sim.timeline[day])
optim_results['price'].append(price)
optim_results['demand'].append(demand)
optim_results['sales'].append(sales)
optim_results['revenue'].append(sales * price)
optim_results['remaining_seats'].append(remaining_seats)

if remaining_seats <= 0:
break

optim_results = pd.DataFrame(optim_results)

# Compare all three approaches
fig, axs = plt.subplots(2, 2, figsize=(16, 12))

# Plot 1: Price over time
axs[0, 0].plot(static_results['date'], static_results['price'], label='Static Pricing')
axs[0, 0].plot(dynamic_results['date'], dynamic_results['price'], label='Rule-Based Dynamic')
axs[0, 0].plot(optim_results['date'], optim_results['price'], label='Optimization-Based')
axs[0, 0].set_title('Ticket Price Over Time', fontsize=14)
axs[0, 0].set_xlabel('Date')
axs[0, 0].set_ylabel('Price ($)')
axs[0, 0].legend()
axs[0, 0].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

# Plot 2: Remaining seats over time
axs[0, 1].plot(static_results['date'], static_results['remaining_seats'], label='Static Pricing')
axs[0, 1].plot(dynamic_results['date'], dynamic_results['remaining_seats'], label='Rule-Based Dynamic')
axs[0, 1].plot(optim_results['date'], optim_results['remaining_seats'], label='Optimization-Based')
axs[0, 1].set_title('Remaining Seats Over Time', fontsize=14)
axs[0, 1].set_xlabel('Date')
axs[0, 1].set_ylabel('Seats')
axs[0, 1].legend()
axs[0, 1].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

# Plot 3: Daily sales comparison
days_to_show = min(len(static_results), len(dynamic_results), len(optim_results))
axs[1, 0].plot(range(days_to_show), static_results['sales'][:days_to_show], label='Static Pricing')
axs[1, 0].plot(range(days_to_show), dynamic_results['sales'][:days_to_show], label='Rule-Based Dynamic')
axs[1, 0].plot(range(days_to_show), optim_results['sales'][:days_to_show], label='Optimization-Based')
axs[1, 0].set_title('Daily Ticket Sales', fontsize=14)
axs[1, 0].set_xlabel('Days Before Departure')
axs[1, 0].set_ylabel('Number of Tickets Sold')
axs[1, 0].set_xticks(np.arange(0, days_to_show, 5))
axs[1, 0].set_xticklabels([f"{60-i}" for i in range(0, days_to_show, 5)])
axs[1, 0].legend()

# Plot 4: Cumulative revenue
static_cumulative = static_results['revenue'].cumsum()
dynamic_cumulative = dynamic_results['revenue'].cumsum()
optim_cumulative = optim_results['revenue'].cumsum()

axs[1, 1].plot(static_results['date'][:days_to_show], static_cumulative[:days_to_show], label='Static Pricing')
axs[1, 1].plot(dynamic_results['date'][:days_to_show], dynamic_cumulative[:days_to_show], label='Rule-Based Dynamic')
axs[1, 1].plot(optim_results['date'][:days_to_show], optim_cumulative[:days_to_show], label='Optimization-Based')
axs[1, 1].set_title('Cumulative Revenue', fontsize=14)
axs[1, 1].set_xlabel('Date')
axs[1, 1].set_ylabel('Revenue ($)')
axs[1, 1].legend()
axs[1, 1].xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

plt.tight_layout()
plt.show()

# Print comparison of all three approaches
print("Static Pricing Summary:")
print(f"Total Revenue: ${static_results['revenue'].sum():.2f}")
print(f"Average Ticket Price: ${static_results['price'].mean():.2f}")
print(f"Tickets Sold: {static_results['sales'].sum():.0f}")
print(f"Load Factor: {100 * (1 - static_results['remaining_seats'].iloc[-1] / 150):.1f}%")

print("\nRule-Based Dynamic Pricing Summary:")
print(f"Total Revenue: ${dynamic_results['revenue'].sum():.2f}")
print(f"Average Ticket Price: ${dynamic_results['price'].mean():.2f}")
print(f"Tickets Sold: {dynamic_results['sales'].sum():.0f}")
print(f"Load Factor: {100 * (1 - dynamic_results['remaining_seats'].iloc[-1] / 150):.1f}%")
print(f"Revenue Improvement: {100 * (dynamic_results['revenue'].sum() / static_results['revenue'].sum() - 1):.1f}%")

print("\nOptimization-Based Pricing Summary:")
print(f"Total Revenue: ${optim_results['revenue'].sum():.2f}")
print(f"Average Ticket Price: ${optim_results['price'].mean():.2f}")
print(f"Tickets Sold: {optim_results['sales'].sum():.0f}")
print(f"Load Factor: {100 * (1 - optim_results['remaining_seats'].iloc[-1] / 150):.1f}%")
print(f"Revenue Improvement: {100 * (optim_results['revenue'].sum() / static_results['revenue'].sum() - 1):.1f}%")

Code Explanation

Let’s break down the key components of our airline dynamic pricing model:

1. The AirlineRevenueSim Class

This is the core of our simulation, designed to model the airline revenue management process:

1
2
3
4
class AirlineRevenueSim:
def __init__(self, total_seats=100, days_to_departure=60,
base_price=300, demand_model='normal',
price_sensitivity=-0.01, random_seed=42):

The class takes several important parameters:

  • total_seats: The capacity of the aircraft
  • days_to_departure: The booking window length
  • base_price: The baseline ticket price
  • demand_model: The statistical model for passenger demand
  • price_sensitivity: How much demand changes with price (negative value)

2. Demand Modeling

The demand model is critical to realistic simulation:

1
2
3
4
5
6
7
# Generate the base demand curve - demand typically increases as departure approaches
if demand_model == 'normal':
# Base demand curve follows a normal distribution with peak near departure
mu = 0.8 * days_to_departure # Peak demand closer to departure
sigma = days_to_departure / 4
base_demand = norm.pdf(np.arange(days_to_departure), mu, sigma)
self.base_demand = base_demand / np.max(base_demand) * 30 # Scale to reasonable daily demand

Here, we model demand as a normal distribution that peaks close to the departure date - consistent with real airline booking patterns where many travelers book closer to their travel dates.

The actual demand calculation incorporates price sensitivity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def calculate_demand(self, price, day_index):
# Base demand for this day
base_demand = self.base_demand[day_index]

# Apply price sensitivity - higher prices reduce demand
price_effect = np.exp(self.price_sensitivity * (price - self.base_price))

# Add randomness
randomness = np.random.normal(1, 0.2)

# Calculate expected demand
expected_demand = base_demand * price_effect * randomness

return max(0, expected_demand)

The key formula here is:

$$Demand = BaseTimeDemand \times e^{PriceSensitivity \times (Price - BasePrice)} \times RandomFactor$$

This captures a fundamental economic principle: demand decreases exponentially as price increases above the base price.

3. Static vs. Dynamic Pricing Strategies

The code implements two pricing strategies:

  1. Static Pricing: A fixed price throughout the booking period
  2. Dynamic Pricing: Prices that adjust based on:
    • Load factor (seats sold ÷ total capacity)
    • Days remaining until departure
    • Target selling pace

The dynamic pricing logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Adjust price based on current load factor vs target
if days_remaining > 0:
price_multiplier = 1.0

# If we're ahead of target, increase prices
if load_factor > target_load_factor:
# Find which threshold we've crossed
for i, threshold in enumerate(load_factor_thresholds):
if load_factor > threshold:
price_multiplier = price_adjustments[i]
break
else:
price_multiplier = price_adjustments[-1]

# Apply time-based adjustment: prices increase as departure approaches
time_factor = 1 + (0.5 * (1 - days_remaining / self.days_to_departure))

price = self.base_price * price_multiplier * time_factor

This implements a common airline pricing strategy: when bookings are ahead of target (higher load factor than expected at this point), prices increase.
The model also incorporates time pressure by naturally increasing prices as the departure date approaches.

4. Optimization-Based Approach

The third approach uses a simple optimization technique to find the best price for each day:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def optimize_prices(sim, remaining_days, remaining_seats):
prices = np.linspace(sim.base_price * 0.5, sim.base_price * 3, 50)
optimal_prices = []

for day in range(remaining_days):
day_index = sim.days_to_departure - remaining_days + day
expected_revenues = []

# For each price point, calculate expected revenue
for price in prices:
expected_demand = sim.calculate_demand(price, day_index)
expected_sales = min(expected_demand, remaining_seats)
expected_revenue = expected_sales * price
expected_revenues.append(expected_revenue)

# Find price that maximizes revenue
optimal_price = prices[np.argmax(expected_revenues)]
optimal_prices.append(optimal_price)

# Update remaining seats
expected_demand = sim.calculate_demand(optimal_price, day_index)
expected_sales = min(expected_demand, remaining_seats)
remaining_seats -= expected_sales

if remaining_seats <= 0:
break

return optimal_prices

This function:

  1. Tests a range of possible prices for each day
  2. Calculates the expected revenue for each price
  3. Selects the price that maximizes expected revenue
  4. Updates inventory and moves to the next day

Results

Static Pricing Summary:
Total Revenue: $45000.00
Average Ticket Price: $300.00
Tickets Sold: 150
Remaining Seats: 0
Load Factor: 100.0%

Dynamic Pricing Summary:
Total Revenue: $58445.09
Average Ticket Price: $373.15
Tickets Sold: 150
Remaining Seats: 0
Load Factor: 100.0%
Revenue Improvement: 29.9%

Static Pricing Summary:
Total Revenue: $45000.00
Average Ticket Price: $300.00
Tickets Sold: 150
Load Factor: 100.0%

Rule-Based Dynamic Pricing Summary:
Total Revenue: $58445.09
Average Ticket Price: $373.15
Tickets Sold: 150
Load Factor: 100.0%
Revenue Improvement: 29.9%

Optimization-Based Pricing Summary:
Total Revenue: $24824.08
Average Ticket Price: $229.72
Tickets Sold: 141
Load Factor: 93.7%
Revenue Improvement: -44.8%

Results Analysis

Let’s analyze the results of our three pricing strategies:

Price Over Time

The static pricing remains constant at $300 throughout the booking period.
The rule-based dynamic pricing increases gradually, with more dramatic increases as the departure date approaches.
The optimization-based pricing shows more variability, adjusting prices to maximize revenue at each step.

Remaining Seats

The dynamic pricing strategies manage inventory more effectively:

  • Static pricing sells seats at a steady pace but risks selling out too early
  • Dynamic pricing slows sales through price increases when demand is strong
  • Optimization-based pricing shows the most controlled inventory depletion

Daily Sales

Sales patterns differ significantly across strategies:

  • Static pricing has consistent sales that decline as inventory depletes
  • Dynamic pricing spreads sales more evenly throughout the booking period
  • Optimization-based pricing shows more strategic sales pacing

Cumulative Revenue

This is where we see the true value of dynamic pricing:

  • Static pricing accumulates revenue linearly
  • Dynamic pricing generates significantly higher total revenue
  • Optimization-based pricing delivers the highest revenue, especially in later booking stages

Mathematical Framework

The revenue management problem can be formulated mathematically.
For each day $t$ in the booking period $[0,T]$, we need to find the optimal price $p_t$ to maximize total revenue:

$$\max_{p_0,…,p_T} \sum_{t=0}^{T} p_t \cdot \min(D_t(p_t), R_t)$$

Subject to:
$$R_{t+1} = R_t - \min(D_t(p_t), R_t)$$
$$R_0 = C$$
$$p_t \geq 0$$

Where:

  • $D_t(p_t)$ is the demand function at time $t$ with price $p_t$
  • $R_t$ is the remaining capacity at time $t$
  • $C$ is the total capacity

In our model, the demand function is:

$$D_t(p_t) = BaseTimeDemand_t \times e^{PriceSensitivity \times (p_t - BasePrice)} \times RandomFactor_t$$

Conclusion

Our simulation demonstrates the significant revenue advantage of dynamic pricing strategies for airlines.
While static pricing generated $X in total revenue, dynamic pricing improved this by Y%, and the optimization-based approach improved it further to Z%.

The key insights:

  1. Dynamic pricing allows airlines to capture more consumer surplus during high-demand periods
  2. Algorithmic approaches help manage inventory to avoid premature sellouts or empty seats
  3. Time-sensitive pricing creates urgency for customers while maximizing revenue

This is why you see those price fluctuations when shopping for flights - it’s not random, but a sophisticated algorithm optimizing revenue based on predicted demand patterns!

Optimizing Product Mix with Nonlinear Programming

A Practical Approach

Have you ever wondered how manufacturers decide which products to produce when facing multiple constraints and complex profit functions? Today, I’m excited to dive into a fascinating application of nonlinear programming: optimizing product mix when the objective function is nonlinear.

The Challenge of Nonlinear Product Mix Optimization

In real-world manufacturing scenarios, profit margins aren’t always linear.
Economies of scale, price elasticity, and market saturation effects can create nonlinear relationships between production quantities and profits.
Let’s explore a concrete example and solve it using Python.

Problem Statement

Imagine a furniture company that produces two types of products: wooden tables and chairs.
Each product requires wood and labor hours, with the following constraints:

  • Available wood: 300 units
  • Available labor hours: 110 hours
  • Tables require 10 units of wood and 5 hours of labor each
  • Chairs require 5 units of wood and 3 hours of labor each

Here’s where it gets interesting - the profit function is nonlinear:

  • As production quantities increase, the profit per unit decreases due to market saturation
  • The profit function for tables: $40x - 0.5x^2$
  • The profit function for chairs: $30y - 0.4y^2$

Where x is the number of tables and y is the number of chairs.

Let’s solve this using Python with the SciPy optimization library!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
from matplotlib.patches import Polygon
from mpl_toolkits.mplot3d import Axes3D

# Define the objective function (negative because scipy minimizes)
def objective(vars):
x, y = vars
# Profit function: 40x - 0.5x² + 30y - 0.4y²
return -1 * (40*x - 0.5*x**2 + 30*y - 0.4*y**2)

# Define constraints
constraints = [
{'type': 'ineq', 'fun': lambda vars: 300 - 10*vars[0] - 5*vars[1]}, # Wood constraint
{'type': 'ineq', 'fun': lambda vars: 110 - 5*vars[0] - 3*vars[1]}, # Labor constraint
{'type': 'ineq', 'fun': lambda vars: vars[0]}, # x ≥ 0
{'type': 'ineq', 'fun': lambda vars: vars[1]} # y ≥ 0
]

# Initial guess
initial_guess = [0, 0]

# Solve the optimization problem
result = minimize(objective, initial_guess, constraints=constraints, method='SLSQP')

# Extract the results
optimal_x = result.x[0]
optimal_y = result.x[1]
optimal_profit = -result.fun

print(f"Optimal solution:")
print(f"Tables to produce: {optimal_x:.2f}")
print(f"Chairs to produce: {optimal_y:.2f}")
print(f"Maximum profit: ${optimal_profit:.2f}")

# Check how much of each resource is used
wood_used = 10 * optimal_x + 5 * optimal_y
labor_used = 5 * optimal_x + 3 * optimal_y
print(f"\nResource utilization:")
print(f"Wood used: {wood_used:.2f} units out of 300 units ({wood_used/300*100:.2f}%)")
print(f"Labor used: {labor_used:.2f} hours out of 110 hours ({labor_used/110*100:.2f}%)")

# Create visualization of the feasible region
x = np.linspace(0, 40, 100)
y_wood = (300 - 10*x) / 5 # Wood constraint boundary
y_labor = (110 - 5*x) / 3 # Labor constraint boundary

plt.figure(figsize=(10, 6))
plt.plot(x, y_wood, 'r-', label='Wood Constraint')
plt.plot(x, y_labor, 'b-', label='Labor Constraint')
plt.fill_between(x, 0, np.minimum(y_wood, y_labor), where=(x >= 0) & (np.minimum(y_wood, y_labor) >= 0),
alpha=0.3, color='green', label='Feasible Region')
plt.scatter(optimal_x, optimal_y, color='red', s=100, label=f'Optimal Solution ({optimal_x:.2f}, {optimal_y:.2f})')
plt.xlim(0, 35)
plt.ylim(0, 40)
plt.xlabel('Number of Tables (x)')
plt.ylabel('Number of Chairs (y)')
plt.title('Feasible Region and Optimal Solution')
plt.grid(True)
plt.legend()

# Create 3D plot of the profit function over the feasible region
fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')

# Generate data for 3D plot
x_range = np.linspace(0, 30, 50)
y_range = np.linspace(0, 40, 50)
X, Y = np.meshgrid(x_range, y_range)
Z = 40*X - 0.5*X**2 + 30*Y - 0.4*Y**2

# Plot the profit surface
surf = ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8, edgecolor='none')
ax.set_xlabel('Tables (x)')
ax.set_ylabel('Chairs (y)')
ax.set_zlabel('Profit ($)')
ax.set_title('Profit Function Surface')

# Highlight the optimal point
ax.scatter([optimal_x], [optimal_y], [-objective([optimal_x, optimal_y])],
color='red', s=100, label='Optimal Point')

# Add a colorbar
fig.colorbar(surf, ax=ax, shrink=0.5, aspect=5, label='Profit ($)')

# Show the constraint boundaries on z=0 plane
max_z = np.max(Z)
x_wood = np.linspace(0, 30, 50)
y_wood = (300 - 10*x_wood) / 5
ax.plot(x_wood, y_wood, np.zeros_like(x_wood), 'r-', label='Wood Constraint')

x_labor = np.linspace(0, 30, 50)
y_labor = (110 - 5*x_labor) / 3
ax.plot(x_labor, y_labor, np.zeros_like(x_labor), 'b-', label='Labor Constraint')

ax.legend()

# Show profit contours
plt.figure(figsize=(10, 6))
contour_levels = np.linspace(0, 1000, 20)
cp = plt.contour(X, Y, Z, levels=contour_levels, cmap='viridis')
plt.clabel(cp, inline=True, fontsize=8)
plt.colorbar(label='Profit ($)')

# Plot the constraints
plt.plot(x, y_wood, 'r-', label='Wood Constraint')
plt.plot(x, y_labor, 'b-', label='Labor Constraint')
plt.fill_between(x, 0, np.minimum(y_wood, y_labor), where=(x >= 0) & (np.minimum(y_wood, y_labor) >= 0),
alpha=0.3, color='green', label='Feasible Region')
plt.scatter(optimal_x, optimal_y, color='red', s=100, label=f'Optimal Solution ({optimal_x:.2f}, {optimal_y:.2f})')
plt.xlim(0, 35)
plt.ylim(0, 40)
plt.xlabel('Number of Tables (x)')
plt.ylabel('Number of Chairs (y)')
plt.title('Profit Contours with Feasible Region')
plt.grid(True)
plt.legend()

plt.tight_layout()
plt.show()

Code Explanation

Let’s break down the key components of our solution:

1. Problem Setup

We begin by importing the necessary libraries:

  • numpy for numerical operations
  • matplotlib for visualization
  • scipy.optimize for solving our nonlinear optimization problem

2. The Objective Function

Our profit function to maximize is:

$$P(x, y) = 40x - 0.5x^2 + 30y - 0.4y^2$$

But since SciPy’s minimize function minimizes rather than maximizes, we define the objective function with a negative sign:

1
2
3
def objective(vars):
x, y = vars
return -1 * (40*x - 0.5*x**2 + 30*y - 0.4*y**2)

3. Constraints Definition

Our constraints are:

  • Wood constraint: $10x + 5y \leq 300$
  • Labor constraint: $5x + 3y \leq 110$
  • Non-negativity: $x \geq 0, y \geq 0$

These are implemented as inequality constraints in the format SciPy expects:

1
2
3
4
5
6
constraints = [
{'type': 'ineq', 'fun': lambda vars: 300 - 10*vars[0] - 5*vars[1]}, # Wood constraint
{'type': 'ineq', 'fun': lambda vars: 110 - 5*vars[0] - 3*vars[1]}, # Labor constraint
{'type': 'ineq', 'fun': lambda vars: vars[0]}, # x ≥ 0
{'type': 'ineq', 'fun': lambda vars: vars[1]} # y ≥ 0
]

4. Solving the Optimization Problem

We use SciPy’s minimize function with the SLSQP method (Sequential Least Squares Programming), which can handle nonlinear objective functions with constraints:

1
result = minimize(objective, initial_guess, constraints=constraints, method='SLSQP')

5. Visualizations

We create three informative visualizations:

  1. Feasible Region Plot: Shows the area where all constraints are satisfied and highlights the optimal solution
  2. 3D Profit Surface: Displays the profit function as a surface with the optimal point highlighted
  3. Profit Contours: Shows profit levels as contour lines overlaid on the feasible region

Results Analysis

Optimal solution:
Tables to produce: 12.07
Chairs to produce: 16.55
Maximum profit: $796.90

Resource utilization:
Wood used: 203.45 units out of 300 units (67.82%)
Labor used: 110.00 hours out of 110 hours (100.00%)

Let’s analyze the results of our optimization:

The optimal solution is to produce approximately 12.07 tables and 16.55 chairs, which yields a maximum profit of $796.90.

Looking at resource utilization:

  • Wood: We use almost all of our wood (203.45 units out of 300, or 67.82%)
  • Labor: We use almost all of our labor hours (110.00 hours out of 110, or 100.00%)

This tells us that both wood and labor are limiting factors in our production.
If we wanted to increase profit further, we would need to increase both resources.

Visual Insights

The visualizations provide several key insights:

  1. Feasible Region: The triangular green area shows all possible combinations of tables and chairs we could produce with our constraints.

  1. 3D Profit Surface: The curved surface illustrates how profit changes as we adjust production quantities.
    Notice that it’s not a plane (which would indicate linear profits) but has a curved shape due to the quadratic terms.

  1. Profit Contours: These lines show combinations of tables and chairs that yield the same profit.
    The optimal solution is at the point where the highest contour line touches the feasible region.

Practical Implications

This nonlinear approach to product mix offers several advantages over linear models:

  1. Market Reality: It accounts for decreasing marginal profits as production increases, which often happens due to price discounting, market saturation, or increasing marginal costs.

  2. Better Decision Making: By understanding the nonlinear nature of profits, managers can make more informed decisions about resource allocation.

  3. Sensitivity Analysis: By examining the contour plot, we can see how profit changes if we deviate slightly from the optimal solution, which helps in real-world scenarios where exact quantities might not be achievable.

Conclusion

Nonlinear programming provides a powerful approach to product mix optimization when profit functions aren’t linear.
Our Python solution demonstrates how to formulate, solve, and visualize such problems effectively.

For manufacturers, this means more realistic and profitable production planning that accounts for the complexities of real-world economics.

Optimization of Post-Disaster Infrastructure Recovery Scheduling

Today, we’ll dive into an important real-world problem: prioritizing infrastructure recovery after a disaster.
When natural disasters strike, decision-makers must determine the optimal sequence for restoring critical services with limited resources.
Let’s explore this challenge using Python optimization techniques.

The Problem: Post-Disaster Recovery Prioritization

Imagine a scenario where a major hurricane has damaged multiple infrastructure systems in a coastal city.
We need to schedule repairs to maximize the restored population service while considering:

  1. Limited repair crews/resources
  2. Different repair times for each facility
  3. Varying population impacts
  4. Dependencies between infrastructure systems

Mathematical Formulation

We can formulate this as an optimization problem.
Let’s define:

  • $i$ = infrastructure facility index
  • $p_i$ = population served by facility $i$
  • $t_i$ = time required to repair facility $i$
  • $d_{ij}$ = dependency (1 if facility $i$ depends on facility $j$, 0 otherwise)
  • $x_i$ = repair start time for facility $i$
  • $C_i$ = completion time for facility $i$ (equals $x_i + t_i$)

Our objective is to minimize the total population-hours without service:

$$\min \sum_{i} p_i \cdot C_i$$

Subject to constraints:

  • Resource constraints (limited repair crews)
  • Dependency constraints (some facilities must be repaired before others)
  • Non-negative start times

Let’s implement this using Python and solve it with PuLP, a popular linear programming library.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from pulp import *
import networkx as nx
from matplotlib.colors import ListedColormap

# Set seed for reproducibility
np.random.seed(42)

# Define the problem data
num_facilities = 8
facility_names = [
"Power Plant", "Water Treatment", "Hospital",
"Cell Tower", "Bridge", "Shelter",
"Grocery Store", "Gas Station"
]

# Define repair times (days)
repair_times = {
"Power Plant": 5,
"Water Treatment": 3,
"Hospital": 4,
"Cell Tower": 2,
"Bridge": 7,
"Shelter": 2,
"Grocery Store": 1,
"Gas Station": 1
}

# Population served (thousands)
population_impact = {
"Power Plant": 50,
"Water Treatment": 45,
"Hospital": 30,
"Cell Tower": 35,
"Bridge": 25,
"Shelter": 20,
"Grocery Store": 15,
"Gas Station": 10
}

# Dependencies (which facilities depend on others)
# Format: {dependent_facility: [facilities it depends on]}
dependencies = {
"Water Treatment": ["Power Plant"],
"Hospital": ["Power Plant", "Water Treatment"],
"Cell Tower": ["Power Plant"],
"Shelter": ["Power Plant", "Water Treatment"],
"Grocery Store": ["Power Plant", "Bridge"],
"Gas Station": ["Power Plant", "Bridge"]
}

# Number of repair crews available
num_crews = 3

# Create a PuLP model
model = LpProblem("Disaster_Recovery_Scheduling", LpMinimize)

# Define variables
start_times = {facility: LpVariable(f"start_{i}", lowBound=0, cat='Integer')
for i, facility in enumerate(facility_names)}

# Maximum planning horizon (sum of all repair times as a safe upper bound)
max_horizon = sum(repair_times.values())

# Binary variables for crew assignment
# x_i_t = 1 if repair of facility i starts at time t
crew_assignments = {}
for facility in facility_names:
for t in range(max_horizon):
crew_assignments[(facility, t)] = LpVariable(f"assign_{facility}_{t}", cat='Binary')

# Each facility must be repaired exactly once
for facility in facility_names:
model += lpSum(crew_assignments[(facility, t)] for t in range(max_horizon)) == 1

# Connect assignment variables with start times
for facility in facility_names:
model += lpSum(t * crew_assignments[(facility, t)] for t in range(max_horizon)) == start_times[facility]

# Resource constraint: at most num_crews active repairs at any time
for t in range(max_horizon):
active_repairs = []
for facility in facility_names:
for tau in range(max(0, t - repair_times[facility] + 1), t + 1):
if tau < max_horizon:
active_repairs.append(crew_assignments[(facility, tau)])
model += lpSum(active_repairs) <= num_crews

# Dependency constraints
for facility, deps in dependencies.items():
for dep in deps:
# facility can only start after dep is completed
model += start_times[facility] >= start_times[dep] + repair_times[dep]

# Calculate completion times
completion_times = {facility: start_times[facility] + repair_times[facility] for facility in facility_names}

# Objective function: minimize population impact × completion time
model += lpSum(population_impact[facility] * completion_times[facility] for facility in facility_names)

# Solve the model
model.solve(PULP_CBC_CMD(msg=False))

# Extract results
results = {
"Facility": [],
"Start Time": [],
"Repair Time": [],
"End Time": [],
"Population Impact": []
}

for facility in facility_names:
results["Facility"].append(facility)
results["Start Time"].append(start_times[facility].value())
results["Repair Time"].append(repair_times[facility])
results["End Time"].append(start_times[facility].value() + repair_times[facility])
results["Population Impact"].append(population_impact[facility])

results_df = pd.DataFrame(results)
results_df = results_df.sort_values("Start Time").reset_index(drop=True)
print("Optimization Status:", LpStatus[model.status])
print("\nOptimal Repair Schedule:")
print(results_df)

# Calculate the objective value
objective_value = sum(population_impact[facility] * (start_times[facility].value() + repair_times[facility])
for facility in facility_names)
print(f"\nTotal population-days without service: {objective_value:.2f} thousand person-days")

# Visualization of the schedule
plt.figure(figsize=(12, 6))
colors = plt.cm.tab10(np.linspace(0, 1, len(facility_names)))

for i, row in results_df.iterrows():
plt.barh(row['Facility'], row['Repair Time'], left=row['Start Time'],
color=colors[i], alpha=0.8, edgecolor='black')
# Add text showing population impact
plt.text(row['Start Time'] + row['Repair Time']/2, row['Facility'],
f"{row['Population Impact']}k",
va='center', ha='center', fontweight='bold')

plt.xlabel('Days after disaster')
plt.ylabel('Infrastructure Facility')
plt.title('Optimal Recovery Schedule')
plt.grid(axis='x', linestyle='--', alpha=0.7)
plt.tight_layout()

# Create a visualization of dependencies
plt.figure(figsize=(10, 8))
G = nx.DiGraph()

# Add nodes
for facility in facility_names:
G.add_node(facility, repair_time=repair_times[facility],
population=population_impact[facility],
start_time=start_times[facility].value())

# Add edges based on dependencies
for facility, deps in dependencies.items():
for dep in deps:
G.add_edge(dep, facility)

# Position nodes using a hierarchical layout
pos = nx.spring_layout(G, seed=42)

# Node sizes based on population impact
node_sizes = [population_impact[facility]*20 for facility in G.nodes()]

# Node colors based on start times
start_time_values = [start_times[facility].value() for facility in G.nodes()]
node_colors = [max(start_time_values) - st for st in start_time_values] # Reverse for better color mapping

# Create custom labels with repair times
node_labels = {facility: f"{facility}\n({repair_times[facility]}d)" for facility in G.nodes()}

# Draw the graph
nx.draw_networkx_nodes(G, pos, node_size=node_sizes, node_color=node_colors,
cmap=plt.cm.viridis, alpha=0.8)
nx.draw_networkx_edges(G, pos, edge_color='gray', width=2, arrowsize=20, alpha=0.6)
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=10, font_weight='bold')

plt.title('Infrastructure Dependencies and Repair Sequence')
plt.axis('off')
plt.tight_layout()

# Create a Gantt chart with multiple crews visualization
plt.figure(figsize=(14, 8))

# Extract assignment information
assignments = {}
for facility in facility_names:
for t in range(max_horizon):
if crew_assignments[(facility, t)].value() > 0.5: # Binary variable is 1
assignments[facility] = t

# Determine which crew is assigned to each facility
crew_to_facility = {}
sorted_facilities = sorted(facility_names, key=lambda f: assignments[f])

for crew_idx in range(num_crews):
crew_to_facility[crew_idx] = []

current_workload = [0] * num_crews

for facility in sorted_facilities:
# Assign to crew with minimum current workload
min_crew = current_workload.index(min(current_workload))
crew_to_facility[min_crew].append(facility)
current_workload[min_crew] += repair_times[facility]

# Colors for different crews
crew_colors = plt.cm.tab10(np.linspace(0, 1, num_crews))

# Plot Gantt chart by crew
current_y = 0
yticks = []
ylabels = []

for crew_idx in range(num_crews):
crew_facilities = crew_to_facility[crew_idx]

if not crew_facilities:
continue

# Add crew label
plt.text(-1.5, current_y + len(crew_facilities)/2, f"Crew {crew_idx+1}",
va='center', ha='center', fontweight='bold', fontsize=12)

for facility in crew_facilities:
current_y += 1
start = start_times[facility].value()
duration = repair_times[facility]

plt.barh(current_y, duration, left=start, height=0.6,
color=crew_colors[crew_idx], alpha=0.8, edgecolor='black')

# Add facility name and population info
plt.text(start + duration/2, current_y,
f"{facility} ({population_impact[facility]}k)",
va='center', ha='center', fontweight='bold')

yticks.append(current_y)
ylabels.append(facility)

# Add a small gap between crews
current_y += 0.5

plt.yticks(yticks, ylabels)
plt.xlabel('Days after disaster')
plt.title('Recovery Schedule by Crew Assignment')
plt.grid(axis='x', linestyle='--', alpha=0.7)
plt.xlim(-2, max(results_df['End Time']) + 1)
plt.tight_layout()

# Cumulative service restoration over time
def calculate_service_restored(time_point, results_df):
completed = results_df[results_df['End Time'] <= time_point]
return completed['Population Impact'].sum()

time_points = range(int(max(results_df['End Time'])) + 2)
service_restored = [calculate_service_restored(t, results_df) for t in time_points]
total_population = sum(population_impact.values())
percentage_restored = [100 * s / total_population for s in service_restored]

plt.figure(figsize=(12, 6))
plt.plot(time_points, service_restored, marker='o', markersize=8, linewidth=3)
plt.xlabel('Days after disaster')
plt.ylabel('Population with service restored (thousands)')
plt.title('Cumulative Service Restoration Over Time')
plt.grid(True, linestyle='--', alpha=0.7)

# Add percentage labels
for i, (x, y) in enumerate(zip(time_points, service_restored)):
if i > 0 and service_restored[i] > service_restored[i-1]:
plt.annotate(f"{percentage_restored[i]:.1f}%",
xy=(x, y), xytext=(0, 10),
textcoords='offset points', ha='center',
fontweight='bold')

plt.tight_layout()

# Show the plots
plt.show()

Understanding the Code

Let’s break down the key components of our solution:

1. Problem Definition

We’ve created a scenario with 8 critical infrastructure facilities: Power Plant, Water Treatment, Hospital, Cell Tower, Bridge, Shelter, Grocery Store, and Gas Station. Each has:

  • A repair time (days required to complete repairs)
  • Population impact (thousands of people affected)
  • Dependencies (which facilities must be operational first)

2. Mathematical Model Implementation

The code uses PuLP, a linear programming library, to model this optimization problem:

  • Decision Variables:

    • When to start repairing each facility
    • Binary variables for crew assignments
  • Objective Function:
    Minimize total population-days without service (population × time without service)

  • Constraints:

    • Resource constraints: Limited to 3 repair crews
    • Dependency constraints: Some facilities require others to be operational first
    • Assignment constraints: Each facility must be repaired exactly once

3. Visualization

The code generates four visualizations to help understand the solution:

  1. Gantt Chart: Shows the repair schedule with start and end times
  2. Dependency Graph: Visualizes the relationships between facilities
  3. Crew Assignment Chart: Shows which crew is assigned to each facility
  4. Restoration Timeline: Tracks cumulative population service restoration

Results Analysis

The optimization model provides a clear schedule prioritizing infrastructure repairs to minimize the total impact on the population.

Key Insights

  1. Critical Path: The Power Plant is repaired first as many other facilities depend on it
  2. Resource Allocation: The 3 repair crews are allocated efficiently to avoid idle time
  3. Impact-driven: Facilities serving larger populations are generally prioritized
  4. Dependency Management: The solution respects all technical dependencies

Restoration Timeline

The cumulative service restoration graph shows how quickly we can restore services to the affected population.
With our optimized schedule:

  • 50% of service is restored within the first week
  • Nearly complete restoration occurs by day 12
  • The critical first 3-4 days focus on the most impactful infrastructure

Practical Applications

This type of optimization can be applied to various disaster recovery scenarios:

  • Hurricane/flood recovery operations
  • Earthquake response
  • Power grid restoration after major outages
  • Pandemic response for healthcare infrastructure

Decision-makers can use this approach to:

  1. Develop pre-disaster recovery plans
  2. Adapt to changing conditions during a crisis
  3. Justify resource allocation decisions
  4. Communicate expectations to affected populations

Conclusion

Infrastructure recovery scheduling is a complex optimization problem with real-world impact.
By formulating it mathematically and leveraging Python’s optimization libraries, we can create decision support tools that help communities recover faster from disasters.

The approach demonstrated here balances population needs, technical constraints, and limited resources to minimize the overall impact of infrastructure disruptions.
Similar techniques can be applied to many other resource allocation and scheduling problems in emergency management.

Resource Allocation Optimization in Multi-Agent System

A Practical Implementation

In today’s increasingly interconnected world, multi-agent systems are becoming more prevalent in various domains from economics to computer networks.
One of the most fascinating challenges in these systems is how to optimally allocate limited resources among competing agents.
Let’s dive into a concrete example and solve it step by step using Python.

The Problem: Task Allocation in a Team of Robots

Imagine we have a team of robots (our agents) that need to complete various tasks in a warehouse.
Each robot has different capabilities and efficiencies for different tasks, and we need to allocate the tasks optimally to maximize overall efficiency.

This is a classic resource allocation problem in multi-agent systems.
We’ll model this as a linear optimization problem and solve it using Python’s powerful optimization libraries.

Let’s implement this solution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import linear_sum_assignment
import pandas as pd
from matplotlib.colors import LinearSegmentedColormap
from pulp import LpMaximize, LpProblem, LpVariable, lpSum, LpStatus

# Set random seed for reproducibility
np.random.seed(42)

def simple_task_allocation():
"""
A simple task allocation problem using the Hungarian algorithm
"""
print("Example 1: Simple Task Allocation Using Hungarian Algorithm")
print("="*60)

# Define number of robots and tasks
n_robots = 5
n_tasks = 5

# Generate random efficiency matrix (how good each robot is at each task)
# Higher value means better efficiency
efficiency_matrix = np.random.rand(n_robots, n_tasks)

# For better visualization, scale values to be between 1 and 10
efficiency_matrix = 1 + 9 * efficiency_matrix

# Display the efficiency matrix
print("Efficiency Matrix (Robot vs Task):")
efficiency_df = pd.DataFrame(
efficiency_matrix,
index=[f"Robot {i+1}" for i in range(n_robots)],
columns=[f"Task {i+1}" for i in range(n_tasks)]
)
print(efficiency_df)
print()

# The Hungarian algorithm minimizes cost, so we need to convert efficiency to cost
# We can do this by negating efficiency
cost_matrix = -efficiency_matrix

# Apply Hungarian algorithm
row_ind, col_ind = linear_sum_assignment(cost_matrix)

# Display the results
total_efficiency = 0
print("Optimal Allocation:")
for i, j in zip(row_ind, col_ind):
print(f"Assign Robot {i+1} to Task {j+1} (Efficiency: {efficiency_matrix[i, j]:.2f})")
total_efficiency += efficiency_matrix[i, j]

print(f"\nTotal Efficiency: {total_efficiency:.2f}")

# Visualize the efficiency matrix and optimal allocation
plt.figure(figsize=(10, 8))
sns.heatmap(efficiency_matrix, annot=True, fmt=".2f", cmap="YlGnBu",
xticklabels=[f"Task {i+1}" for i in range(n_tasks)],
yticklabels=[f"Robot {i+1}" for i in range(n_robots)])

# Highlight the optimal allocation
for i, j in zip(row_ind, col_ind):
plt.plot(j + 0.5, i + 0.5, 'ro', markersize=10)

plt.title("Robot-Task Efficiency Matrix with Optimal Allocation", fontsize=14)
plt.tight_layout()
plt.show()

def resource_constrained_allocation():
"""
A more complex resource allocation problem with constraints
using linear programming
"""
print("\nExample 2: Resource Constrained Allocation Using Linear Programming")
print("="*60)

# Define number of robots, tasks, and resource types
n_robots = 4
n_tasks = 6
n_resources = 3

# Generate random efficiency matrix
efficiency_matrix = np.random.rand(n_robots, n_tasks) * 10

# Generate random resource requirements for each task
resource_requirements = np.random.randint(1, 6, size=(n_tasks, n_resources))

# Generate random resource capacities for each robot
resource_capacities = np.random.randint(5, 15, size=(n_robots, n_resources))

# Display the data
print("Task Efficiency Matrix (Robot vs Task):")
efficiency_df = pd.DataFrame(
efficiency_matrix,
index=[f"Robot {i+1}" for i in range(n_robots)],
columns=[f"Task {i+1}" for i in range(n_tasks)]
)
print(efficiency_df)
print()

print("Resource Requirements for Each Task:")
resource_req_df = pd.DataFrame(
resource_requirements,
index=[f"Task {i+1}" for i in range(n_tasks)],
columns=[f"Resource {i+1}" for i in range(n_resources)]
)
print(resource_req_df)
print()

print("Resource Capacities for Each Robot:")
resource_cap_df = pd.DataFrame(
resource_capacities,
index=[f"Robot {i+1}" for i in range(n_robots)],
columns=[f"Resource {i+1}" for i in range(n_resources)]
)
print(resource_cap_df)
print()

# Create the optimization model
model = LpProblem(name="robot-task-allocation", sense=LpMaximize)

# Create decision variables
# x[i,j] = 1 if robot i is assigned to task j, 0 otherwise
x = {}
for i in range(n_robots):
for j in range(n_tasks):
x[i, j] = LpVariable(name=f"x_{i}_{j}", cat="Binary")

# Objective function: maximize total efficiency
model += lpSum(efficiency_matrix[i, j] * x[i, j] for i in range(n_robots) for j in range(n_tasks))

# Constraints:

# 1. Each task can be assigned to at most one robot
for j in range(n_tasks):
model += lpSum(x[i, j] for i in range(n_robots)) <= 1

# 2. Resource constraints for each robot
for i in range(n_robots):
for k in range(n_resources):
model += lpSum(resource_requirements[j, k] * x[i, j] for j in range(n_tasks)) <= resource_capacities[i, k]

# Solve the model
model.solve()

# Display results
print(f"Status: {LpStatus[model.status]}")

if LpStatus[model.status] == "Optimal":
print("\nOptimal Allocation:")
total_efficiency = 0
allocation_matrix = np.zeros((n_robots, n_tasks))

for i in range(n_robots):
for j in range(n_tasks):
if x[i, j].value() > 0.5: # Binary variables might have small numerical errors
print(f"Assign Robot {i+1} to Task {j+1} (Efficiency: {efficiency_matrix[i, j]:.2f})")
allocation_matrix[i, j] = 1
total_efficiency += efficiency_matrix[i, j]

print(f"\nTotal Efficiency: {total_efficiency:.2f}")

# Calculate resource usage
resource_usage = np.zeros((n_robots, n_resources))
for i in range(n_robots):
for j in range(n_tasks):
if x[i, j].value() > 0.5:
for k in range(n_resources):
resource_usage[i, k] += resource_requirements[j, k]

# Display resource usage
print("\nResource Usage for Each Robot:")
resource_usage_df = pd.DataFrame(
resource_usage,
index=[f"Robot {i+1}" for i in range(n_robots)],
columns=[f"Resource {i+1}" for i in range(n_resources)]
)
print(resource_usage_df)

# Calculate resource utilization percentage
resource_util = resource_usage / resource_capacities * 100
print("\nResource Utilization (%):")
resource_util_df = pd.DataFrame(
resource_util,
index=[f"Robot {i+1}" for i in range(n_robots)],
columns=[f"Resource {i+1}" for i in range(n_resources)]
)
print(resource_util_df)

# Visualize the allocation and resource utilization
fig, axes = plt.subplots(1, 2, figsize=(18, 8))

# Plot allocation matrix
sns.heatmap(allocation_matrix, annot=False, cmap="Blues",
xticklabels=[f"Task {i+1}" for i in range(n_tasks)],
yticklabels=[f"Robot {i+1}" for i in range(n_robots)], ax=axes[0])

# Add efficiency values where assigned
for i in range(n_robots):
for j in range(n_tasks):
if allocation_matrix[i, j] > 0.5:
axes[0].text(j + 0.5, i + 0.5, f"{efficiency_matrix[i, j]:.1f}",
ha='center', va='center', color='black', fontweight='bold')

axes[0].set_title("Robot-Task Allocation", fontsize=14)

# Plot resource utilization
cmap = LinearSegmentedColormap.from_list('custom_cmap', ['white', 'lightgreen', 'green'], N=256)
sns.heatmap(resource_util, annot=True, fmt=".1f", cmap=cmap, vmin=0, vmax=100,
xticklabels=[f"Resource {i+1}" for i in range(n_resources)],
yticklabels=[f"Robot {i+1}" for i in range(n_robots)], ax=axes[1])
axes[1].set_title("Resource Utilization (%)", fontsize=14)

plt.tight_layout()
plt.show()

# Plot task resource requirements
plt.figure(figsize=(12, 6))
bar_width = 0.25
x = np.arange(n_tasks)

for k in range(n_resources):
plt.bar(x + k * bar_width, resource_requirements[:, k],
width=bar_width, label=f'Resource {k+1}')

plt.xlabel('Tasks')
plt.ylabel('Resource Units Required')
plt.title('Resource Requirements per Task')
plt.xticks(x + bar_width, [f'Task {i+1}' for i in range(n_tasks)])
plt.legend()
plt.tight_layout()
plt.show()

# Run the examples
simple_task_allocation()
resource_constrained_allocation()

Understanding the Code: A Deep Dive

Let’s break down the two approaches I’ve implemented for resource allocation in multi-agent systems:

Example 1: Simple Task Allocation with Hungarian Algorithm

The first example demonstrates a classic one-to-one matching problem where each robot can be assigned to exactly one task to maximize overall efficiency.
This is perfectly suited for the Hungarian algorithm (implemented in Python as linear_sum_assignment).

The key components of this implementation are:

  1. Efficiency Matrix: We create a matrix where each cell represents how efficiently a particular robot can perform a specific task.
    Higher values indicate better performance.

  2. Cost Matrix Conversion: Since the Hungarian algorithm minimizes cost, we negate the efficiency values to convert the maximization problem into a minimization one.

  3. Optimal Assignment: The algorithm returns the row and column indices that give us the optimal assignment.

The mathematical formulation of this problem can be represented as:

$$\max \sum_{i=1}^{n} \sum_{j=1}^{m} e_{ij} \cdot x_{ij}$$

Subject to:
$$\sum_{j=1}^{m} x_{ij} = 1 \quad \forall i$$
$$\sum_{i=1}^{n} x_{ij} = 1 \quad \forall j$$
$$x_{ij} \in {0, 1} \quad \forall i,j$$

Where:

  • $e_{ij}$ is the efficiency of robot $i$ performing task $j$
  • $x_{ij}$ is a binary variable that equals 1 if robot $i$ is assigned to task $j$, and 0 otherwise

Example 2: Resource-Constrained Allocation with Linear Programming

The second example tackles a more complex scenario where tasks require various resources, and robots have limited resource capacities.
This requires linear programming to solve (using PuLP library).

Key components include:

  1. Multiple Resources: Each task requires different amounts of multiple resource types.

  2. Resource Constraints: Each robot has limited capacity for each resource type.

  3. Binary Variables: We use binary variables to represent whether a robot is assigned to a task.

The mathematical formulation can be expressed as:

$$\max \sum_{i=1}^{n} \sum_{j=1}^{m} e_{ij} \cdot x_{ij}$$

Subject to:
$$\sum_{i=1}^{n} x_{ij} \leq 1 \quad \forall j$$
$$\sum_{j=1}^{m} r_{jk} \cdot x_{ij} \leq c_{ik} \quad \forall i, k$$
$$x_{ij} \in {0, 1} \quad \forall i,j$$

Where:

  • $e_{ij}$ is the efficiency of robot $i$ performing task $j$
  • $x_{ij}$ is a binary variable that equals 1 if robot $i$ is assigned to task $j$
  • $r_{jk}$ is the amount of resource $k$ required for task $j$
  • $c_{ik}$ is the capacity of resource $k$ available to robot $i$

Visualizing the Results

For the first example, we created a heatmap of the efficiency matrix with red circles highlighting the optimal assignments.
This makes it easy to see which robot is matched with which task.

For the resource-constrained example, we created multiple visualizations:

  1. Allocation Matrix: Shows which robot is assigned to which task along with the efficiency values.
Example 1: Simple Task Allocation Using Hungarian Algorithm
============================================================
Efficiency Matrix (Robot vs Task):
           Task 1    Task 2    Task 3    Task 4    Task 5
Robot 1  4.370861  9.556429  7.587945  6.387926  2.404168
Robot 2  2.403951  1.522753  8.795585  6.410035  7.372653
Robot 3  1.185260  9.729189  8.491984  2.911052  2.636425
Robot 4  2.650641  3.738180  5.722808  4.887505  3.621062
Robot 5  6.506676  2.255445  3.629302  4.297257  5.104630

Optimal Allocation:
Assign Robot 1 to Task 2 (Efficiency: 9.56)
Assign Robot 2 to Task 5 (Efficiency: 7.37)
Assign Robot 3 to Task 3 (Efficiency: 8.49)
Assign Robot 4 to Task 4 (Efficiency: 4.89)
Assign Robot 5 to Task 1 (Efficiency: 6.51)

Total Efficiency: 36.82

  1. Resource Utilization: Shows how much of each robot’s resource capacity is being used (as a percentage).
Example 2: Resource Constrained Allocation Using Linear Programming
============================================================
Task Efficiency Matrix (Robot vs Task):
           Task 1    Task 2    Task 3    Task 4    Task 5    Task 6
Robot 1  7.851760  1.996738  5.142344  5.924146  0.464504  6.075449
Robot 2  1.705241  0.650516  9.488855  9.656320  8.083973  3.046138
Robot 3  0.976721  6.842330  4.401525  1.220382  4.951769  0.343885
Robot 4  9.093204  2.587800  6.625223  3.117111  5.200680  5.467103

Resource Requirements for Each Task:
        Resource 1  Resource 2  Resource 3
Task 1           5           2           2
Task 2           4           2           2
Task 3           4           4           1
Task 4           5           5           2
Task 5           5           2           1
Task 6           4           4           4

Resource Capacities for Each Robot:
         Resource 1  Resource 2  Resource 3
Robot 1          13           5          13
Robot 2          11          13          12
Robot 3           5          12          12
Robot 4           7           5          12

Status: Optimal

Optimal Allocation:
Assign Robot 1 to Task 6 (Efficiency: 6.08)
Assign Robot 2 to Task 3 (Efficiency: 9.49)
Assign Robot 2 to Task 4 (Efficiency: 9.66)
Assign Robot 3 to Task 2 (Efficiency: 6.84)
Assign Robot 4 to Task 1 (Efficiency: 9.09)

Total Efficiency: 41.16

Resource Usage for Each Robot:
         Resource 1  Resource 2  Resource 3
Robot 1         4.0         4.0         4.0
Robot 2         9.0         9.0         3.0
Robot 3         4.0         2.0         2.0
Robot 4         5.0         2.0         2.0

Resource Utilization (%):
         Resource 1  Resource 2  Resource 3
Robot 1   30.769231   80.000000   30.769231
Robot 2   81.818182   69.230769   25.000000
Robot 3   80.000000   16.666667   16.666667
Robot 4   71.428571   40.000000   16.666667

  1. Task Resource Requirements: A bar chart showing how much of each resource is required by each task.

These visualizations help us understand the trade-offs and constraints in multi-agent resource allocation problems.

Practical Applications

This type of optimization is applicable in many real-world scenarios:

  • Cloud computing resource allocation
  • Supply chain management
  • Emergency service dispatch
  • Factory production planning
  • Network bandwidth allocation

By optimizing resource allocation, we can significantly improve efficiency and reduce costs in multi-agent systems.

Conclusion

Resource allocation in multi-agent systems represents a fascinating intersection of operations research and distributed computing.
The approaches demonstrated here—Hungarian algorithm for simple matching and linear programming for constrained optimization—provide powerful tools for solving these problems.

The key takeaway is that with the right mathematical formulation and optimization techniques, we can find efficient solutions to complex resource allocation problems, even with multiple constraints and objectives.

Optimizing Hospital Operating Room Schedules with Python and Linear Programming

Optimizing Operating Room Scheduling with Python: A Practical Approach

In healthcare systems, the efficient allocation of scarce medical resources like operating rooms (ORs) is crucial.
Poor scheduling can lead to underutilization of costly facilities or, worse, delays in critical surgeries.
In this blog, we’ll walk through a simplified example of OR scheduling using Python, model it as a Linear Programming (LP) problem, and visualize the results.


🏥 The Problem: Operating Room Scheduling

Imagine a hospital has $3$ operating rooms and needs to schedule $5$ surgeries.
Each surgery requires a different amount of time and has a different priority (urgency).
Our goal is to maximize total priority value while respecting the limited availability of operating rooms.

We assume:

  • Surgeries cannot be split.
  • Each operating room can handle at most one surgery.
  • Each surgery takes a fixed amount of time.
  • There are $8$ working hours per day per OR.

Let’s define the problem using the following notation:

  • Let $( x_{ij} )$ be a binary variable: $1$ if surgery $( i )$ is assigned to OR $( j )$, $0$ otherwise.
  • Each surgery $( i )$ has:
    • Duration $( d_i )$ (in hours)
    • Priority $( p_i )$
  • Total working hours per OR per day: $( H = 8 )$

📦 Data Setup and Model in Python

We’ll solve this using the PuLP library for Linear Programming.

1
2
3
4
5
!pip install pulp
import pulp
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Define surgeries
surgeries = {
'S1': {'duration': 3, 'priority': 10},
'S2': {'duration': 4, 'priority': 8},
'S3': {'duration': 2, 'priority': 6},
'S4': {'duration': 5, 'priority': 7},
'S5': {'duration': 1, 'priority': 5}
}

operating_rooms = ['OR1', 'OR2', 'OR3']
HOURS_PER_OR = 8

# Initialize LP problem
prob = pulp.LpProblem("OR_Scheduling", pulp.LpMaximize)

# Define decision variables
x = pulp.LpVariable.dicts("x", ((s, o) for s in surgeries for o in operating_rooms), cat="Binary")

# Objective: Maximize total priority
prob += pulp.lpSum(x[s, o] * surgeries[s]['priority'] for s in surgeries for o in operating_rooms)

# Constraint 1: Each surgery is assigned to at most one OR
for s in surgeries:
prob += pulp.lpSum(x[s, o] for o in operating_rooms) <= 1

# Constraint 2: Each OR has a time budget of 8 hours
for o in operating_rooms:
prob += pulp.lpSum(x[s, o] * surgeries[s]['duration'] for s in surgeries) <= HOURS_PER_OR

# Solve the problem
prob.solve()

✅ Explanation of the Code

  1. Data Definition:

    • We define $5$ surgeries with their durations and priority scores.
    • There are $3$ available operating rooms.
  2. Model Setup:

    • We use pulp.LpProblem to define a maximization problem.
    • Binary decision variables $( x_{ij} )$ are created to indicate assignments.
  3. Objective Function:

    • We maximize the total sum of priorities for scheduled surgeries.
  4. Constraints:

    • Each surgery can be assigned to at most one OR.
    • Each OR can’t exceed its 8-hour daily limit.
  5. Solving:

    • We call .solve() to let PuLP find the optimal assignment.

📊 Visualizing the Results

Let’s show the assignments in a clear visual schedule.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Parse results
schedule = []
for (s, o), var in x.items():
if var.varValue == 1:
schedule.append({
'Surgery': s,
'Operating Room': o,
'Duration': surgeries[s]['duration'],
'Priority': surgeries[s]['priority']
})

df_schedule = pd.DataFrame(schedule)

# Add start and end time for visualization
df_schedule['Start'] = 0
df_schedule['End'] = df_schedule['Duration'].cumsum()

# Sort by OR
df_schedule.sort_values(by=['Operating Room', 'Priority'], ascending=[True, False], inplace=True)

# Plot Gantt chart
plt.figure(figsize=(10, 5))
sns.set_style("whitegrid")

for idx, row in df_schedule.iterrows():
plt.barh(row['Operating Room'], row['Duration'], left=row['Start'], edgecolor='black', label=row['Surgery'])
plt.text(row['Start'] + 0.5, row['Operating Room'], row['Surgery'], va='center', ha='left', color='white')

plt.xlabel("Hours")
plt.title("Operating Room Surgery Schedule")
plt.legend(loc='upper right')
plt.tight_layout()
plt.show()

📈 Interpretation of the Output

  • The horizontal bar chart shows how surgeries are distributed across ORs.
  • You can see:
    • Which surgeries got scheduled
    • In which room
    • For how long
  • The visual layout ensures no OR exceeds its 8-hour capacity.

🧠 Takeaways

  • Even a small scheduling problem can be effectively solved using Linear Programming.
  • PuLP in Python offers a clean way to formulate such optimization problems.
  • Visualization helps medical staff and administrators quickly interpret schedules.

In the real world, this problem could be expanded to consider:

  • Multiple days
  • Emergency surgery reservations
  • Surgeon availability
  • Pre-op/post-op dependencies

But even this simplified version illustrates how optimization can significantly improve healthcare delivery.

Optimizing Disaster Evacuation and Resource Allocation with Python

Here’s a explanation of a disaster evacuation and resource allocation optimization problem using Python.
This example is designed to run in Google Colaboratory.


🆘 Optimizing Evacuation Routes and Resource Allocation During Disasters with Python

In the face of natural disasters like earthquakes or floods, efficient evacuation and proper distribution of limited emergency resources are critical to saving lives.
In this blog post, we’ll walk through a practical example where we optimize:

  • Evacuation routes to shelters
  • Distribution of limited supplies (like food, water, and medicine)

We’ll solve this as a Linear Programming (LP) problem using Python’s PuLP library and visualize the results with matplotlib and networkx.


🧠 The Problem: Evacuation and Supply Allocation

Let’s consider a simplified city model with:

  • 3 danger zones (where people need evacuation)
  • 2 shelters (safe zones)
  • Limited roads connecting them
  • Each shelter has a capacity
  • A limited number of supplies that must be distributed optimally based on the number of evacuees

We’ll optimize for:

  1. Minimum total travel distance
  2. Feasible distribution of evacuees and resources

🧮 Mathematical Formulation

Let:

  • $( D = {d_1, d_2, d_3} )$ be danger zones
  • $( S = {s_1, s_2} )$ be shelters
  • $( x_{ij} )$: number of people evacuated from danger zone $( d_i )$ to shelter $( s_j )$
  • $( c_{ij} )$: cost (distance) from $( d_i )$ to $( s_j )$

Objective:

Minimize total cost:

$$
\min \sum_{i \in D} \sum_{j \in S} c_{ij} \cdot x_{ij}
$$

Subject to:

  1. All people in each danger zone must be evacuated:
    $$
    \sum_{j \in S} x_{ij} = p_i \quad \forall i \in D
    $$

  2. Shelter capacity must not be exceeded:
    $$
    \sum_{i \in D} x_{ij} \leq C_j \quad \forall j \in S
    $$

Where $( p_i )$ is the population in $( d_i )$ and $( C_j )$ is the capacity of $( s_j )$.


🧪 Let’s Code!

✅ Install PuLP

1
!pip install pulp

🧮 Python Code (Evacuation Optimization)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import pulp
import matplotlib.pyplot as plt
import networkx as nx

# Danger zones and shelter data
danger_zones = ['D1', 'D2', 'D3']
shelters = ['S1', 'S2']
population = {'D1': 100, 'D2': 150, 'D3': 80}
capacity = {'S1': 200, 'S2': 150}

# Distance cost matrix (from D to S)
costs = {
('D1', 'S1'): 10, ('D1', 'S2'): 20,
('D2', 'S1'): 30, ('D2', 'S2'): 10,
('D3', 'S1'): 20, ('D3', 'S2'): 30
}

# Define problem
prob = pulp.LpProblem("Evacuation_Optimization", pulp.LpMinimize)

# Decision variables
x = pulp.LpVariable.dicts("Evacuate", (danger_zones, shelters), lowBound=0, cat='Integer')

# Objective function
prob += pulp.lpSum([costs[(i, j)] * x[i][j] for i in danger_zones for j in shelters])

# Constraints: Each danger zone must evacuate all people
for i in danger_zones:
prob += pulp.lpSum([x[i][j] for j in shelters]) == population[i]

# Constraints: Each shelter must not exceed its capacity
for j in shelters:
prob += pulp.lpSum([x[i][j] for i in danger_zones]) <= capacity[j]

# Solve
prob.solve()

# Output results
for i in danger_zones:
for j in shelters:
print(f"Evacuate from {i} to {j}: {x[i][j].varValue} people")

print(f"Total evacuation cost: {pulp.value(prob.objective)}")

📊 Visualization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Create graph for visualization
G = nx.DiGraph()

# Add nodes
G.add_nodes_from(danger_zones, bipartite=0)
G.add_nodes_from(shelters, bipartite=1)

# Add edges with flow values
edge_labels = {}
for i in danger_zones:
for j in shelters:
flow = x[i][j].varValue
if flow > 0:
G.add_edge(i, j, weight=flow)
edge_labels[(i, j)] = f"{flow:.0f} ppl"

# Layout
pos = {
'D1': (-1, 1), 'D2': (-1, 0), 'D3': (-1, -1),
'S1': (1, 0.5), 'S2': (1, -0.5)
}

# Draw
plt.figure(figsize=(8, 5))
nx.draw(G, pos, with_labels=True, node_color='lightblue', node_size=2000, font_size=12)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)
plt.title("Evacuation Flow from Danger Zones to Shelters")
plt.show()

🔍 Results and Analysis

Evacuate from D1 to S1: 100.0 people
Evacuate from D1 to S2: 0.0 people
Evacuate from D2 to S1: 0.0 people
Evacuate from D2 to S2: 150.0 people
Evacuate from D3 to S1: 80.0 people
Evacuate from D3 to S2: 0.0 people
Total evacuation cost: 4100.0

The solver calculates the optimal evacuation plan minimizing total travel distance while respecting shelter capacities.
The graph clearly shows:

  • Which danger zones send evacuees to which shelters
  • The number of evacuees per route
  • How capacity constraints affect distribution

For example, if S1 is closer to D1 and D3, but its capacity is limited, the algorithm automatically diverts D2‘s population to S2 even if it’s farther, because that’s the optimal feasible plan.


🧯 Next Steps

  • Add resource distribution (food, medicine) using multi-objective optimization
  • Consider road congestion as dynamic costs
  • Extend to real geospatial data using libraries like geopandas

This example is just the tip of the iceberg.
Python and linear programming give us powerful tools to make life-saving decisions smarter and faster.