Optimisation with SimPy: Finding the Best Configuration
Simulation tells you what happens. Optimisation tells you what's best.
The Problem
You have a simulation. It has parameters (servers, buffers, arrival rates). You want to find the values that minimise cost, maximise throughput, or hit a target.
Simple Grid Search
import simpy
import random
import numpy as np
def run_simulation(num_servers, arrival_rate, service_rate, duration=1000):
"""Run simulation and return mean wait time."""
random.seed(42)
env = simpy.Environment()
server = simpy.Resource(env, capacity=num_servers)
wait_times = []
def customer():
arrival = env.now
with server.request() as req:
yield req
wait_times.append(env.now - arrival)
yield env.timeout(random.expovariate(service_rate))
def arrivals():
while True:
yield env.timeout(random.expovariate(arrival_rate))
env.process(customer())
env.process(arrivals())
env.run(until=duration)
return np.mean(wait_times) if wait_times else float('inf')
# Grid search
results = []
for servers in range(1, 6):
for arrival_rate in [0.5, 1.0, 1.5, 2.0]:
wait = run_simulation(servers, arrival_rate, service_rate=0.5)
results.append({
'servers': servers,
'arrival_rate': arrival_rate,
'mean_wait': wait
})
# Find best
best = min(results, key=lambda x: x['mean_wait'])
print(f"Best config: {best}")
Cost-Based Optimisation
def total_cost(num_servers, arrival_rate, service_rate):
"""Calculate total cost including wait time cost."""
SERVER_COST = 100 # Cost per server per hour
WAIT_COST = 50 # Cost per customer per minute wait
# Run simulation
mean_wait = run_simulation(num_servers, arrival_rate, service_rate)
customers_per_hour = arrival_rate * 60
# Calculate costs
server_cost = num_servers * SERVER_COST
wait_cost = customers_per_hour * mean_wait * WAIT_COST
return server_cost + wait_cost
# Search for minimum cost
for servers in range(1, 10):
cost = total_cost(servers, arrival_rate=2, service_rate=0.5)
print(f"Servers: {servers}, Cost: {cost:.2f}")
Using scipy.optimize
from scipy.optimize import minimize_scalar, minimize
def objective(num_servers):
"""Objective function to minimize."""
num_servers = int(round(num_servers))
if num_servers < 1:
return float('inf')
mean_wait = run_simulation(num_servers, arrival_rate=2, service_rate=0.5)
server_cost = num_servers * 100
wait_cost = mean_wait * 50 * 120 # 2 arrivals/min * 60 min
return server_cost + wait_cost
# Optimize
result = minimize_scalar(objective, bounds=(1, 20), method='bounded')
optimal_servers = int(round(result.x))
print(f"Optimal servers: {optimal_servers}")
print(f"Minimum cost: {result.fun:.2f}")
Multi-Parameter Optimisation
def multi_objective(params):
"""Optimize multiple parameters."""
num_servers = int(round(params[0]))
buffer_size = int(round(params[1]))
if num_servers < 1 or buffer_size < 1:
return float('inf')
# Run simulation with these parameters
result = run_simulation_complex(num_servers, buffer_size)
# Weighted objective
return (result['wait'] * 50 +
result['abandonment_rate'] * 1000 +
num_servers * 100)
# Optimize
result = minimize(
multi_objective,
x0=[3, 10], # Initial guess
method='Nelder-Mead',
bounds=[(1, 10), (5, 50)]
)
print(f"Optimal: servers={result.x[0]:.0f}, buffer={result.x[1]:.0f}")
Target-Based Optimisation
Find minimum resources to meet a service level:
def meets_target(num_servers, target_wait=5, target_percentile=0.9):
"""Check if configuration meets service target."""
# Run multiple replications for confidence
waits = []
for seed in range(10):
wait = run_simulation(num_servers, arrival_rate=2, service_rate=0.5, seed=seed)
waits.append(wait)
percentile_wait = np.percentile(waits, target_percentile * 100)
return percentile_wait <= target_wait
# Binary search for minimum servers
low, high = 1, 20
while low < high:
mid = (low + high) // 2
if meets_target(mid):
high = mid
else:
low = mid + 1
print(f"Minimum servers to meet target: {low}")
Genetic Algorithm
# Note: Requires deap library
from deap import base, creator, tools, algorithms
import random
# Define fitness (minimize wait, minimize cost)
creator.create("FitnessMin", base.Fitness, weights=(-1.0,))
creator.create("Individual", list, fitness=creator.FitnessMin)
toolbox = base.Toolbox()
# Genes: [servers, buffer_size]
toolbox.register("servers", random.randint, 1, 10)
toolbox.register("buffer", random.randint, 5, 50)
toolbox.register("individual", tools.initCycle, creator.Individual,
(toolbox.servers, toolbox.buffer), n=1)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)
def evaluate(individual):
servers, buffer = individual[0], individual[1]
cost = run_simulation_with_cost(servers, buffer)
return (cost,)
toolbox.register("evaluate", evaluate)
toolbox.register("mate", tools.cxTwoPoint)
toolbox.register("mutate", tools.mutUniformInt, low=[1, 5], up=[10, 50], indpb=0.2)
toolbox.register("select", tools.selTournament, tournsize=3)
# Run GA
population = toolbox.population(n=50)
result, log = algorithms.eaSimple(population, toolbox, cxpb=0.5, mutpb=0.2,
ngen=20, verbose=True)
best = tools.selBest(result, k=1)[0]
print(f"Best solution: servers={best[0]}, buffer={best[1]}")
OptTech: Simulation-Optimisation Framework
class SimulationOptimizer:
def __init__(self, simulation_func, param_bounds):
self.sim_func = simulation_func
self.bounds = param_bounds
self.history = []
def objective(self, params):
"""Run simulation and return objective value."""
result = self.sim_func(**dict(zip(self.bounds.keys(), params)))
self.history.append({'params': params, 'result': result})
return result
def optimize_grid(self, grid_points=5):
"""Grid search optimization."""
import itertools
grids = []
for name, (low, high) in self.bounds.items():
grids.append(np.linspace(low, high, grid_points))
best_params = None
best_value = float('inf')
for combo in itertools.product(*grids):
value = self.objective(combo)
if value < best_value:
best_value = value
best_params = combo
return dict(zip(self.bounds.keys(), best_params)), best_value
def optimize_scipy(self, x0=None):
"""scipy.optimize-based search."""
if x0 is None:
x0 = [(b[0] + b[1]) / 2 for b in self.bounds.values()]
result = minimize(
self.objective,
x0,
method='Nelder-Mead'
)
return dict(zip(self.bounds.keys(), result.x)), result.fun
# Usage
optimizer = SimulationOptimizer(
simulation_func=run_simulation_with_params,
param_bounds={
'servers': (1, 10),
'buffer': (5, 50),
'threshold': (0.5, 0.9)
}
)
best_params, best_value = optimizer.optimize_scipy()
print(f"Optimal: {best_params}, Value: {best_value}")
Handling Stochasticity
Simulation is random. Same parameters give different results.
def robust_objective(params, n_replications=10):
"""Average over multiple replications."""
results = []
for seed in range(n_replications):
result = run_simulation(*params, seed=seed)
results.append(result)
return np.mean(results) # Or use worst-case, percentile, etc.
def percentile_objective(params, n_replications=10, percentile=90):
"""Optimize for percentile (robust to variability)."""
results = []
for seed in range(n_replications):
result = run_simulation(*params, seed=seed)
results.append(result)
return np.percentile(results, percentile)
Summary
Optimisation approaches: - Grid search - Simple, exhaustive - scipy.optimize - Efficient gradient-free - Genetic algorithms - Complex landscapes - Target-based - Meet constraints
Simulation finds what happens. Optimisation finds what's best.
Next Steps
Discover the Power of Simulation
Want to become a go-to expert in simulation with Python? The Complete Simulation Bootcamp will show you how simulation can transform your career and your projects.
Explore the Bootcamp