Skip to content

Commit fe2d821

Browse files
committed
Add simulated annealing package with GUI, TSP helper, and unit tests
1 parent 2da7ea8 commit fe2d821

File tree

6 files changed

+170
-5
lines changed

6 files changed

+170
-5
lines changed

simulated_annealing/example.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,28 @@ def cli_example():
2929
print("Cost:", cost)
3030

3131

32+
def tsp_example():
33+
# Small TSP demo
34+
from .simulated_annealing import SimulatedAnnealing
35+
from .tsp import make_tsp_cost, random_tour, vector_to_tour
36+
37+
coords = [(0, 0), (1, 5), (5, 4), (6, 1), (3, -2)]
38+
n = len(coords)
39+
init_tour = random_tour(n)
40+
# represent tour as vector by using tour indices as values (so ranking recovers order)
41+
initial = [float(i) for i in init_tour]
42+
cost_fn = make_tsp_cost(coords)
43+
44+
sa = SimulatedAnnealing(cost_fn, initial, temperature=100.0, cooling_rate=0.995, iterations_per_temp=500)
45+
best, cost, history = sa.optimize()
46+
best_tour = vector_to_tour(best)
47+
print("Best tour:", best_tour)
48+
print("Cost:", cost)
49+
50+
3251
if __name__ == "__main__":
52+
# Run CLI examples
53+
print("Running continuous example...")
3354
cli_example()
55+
print("Running TSP example...")
56+
tsp_example()

simulated_annealing/gui.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,24 @@ def _on_run(self):
121121

122122
def worker():
123123
sa = SimulatedAnnealing(func, initial, bounds=bounds, temperature=temp, cooling_rate=cooling, iterations_per_temp=iterations)
124-
best, cost, history = sa.optimize()
125-
# update plot on main thread
124+
125+
def progress_cb(step, best_cost, current_cost):
126+
# schedule a plot update on the main thread
127+
self.after(0, lambda: self._update_plot_partial(step, best_cost))
128+
129+
best, cost, history = sa.optimize(stop_event=self.stop_flag, progress_callback=progress_cb)
130+
# update final plot on main thread
126131
self.after(0, lambda: self._on_complete(best, cost, history))
127132

128133
t = threading.Thread(target=worker, daemon=True)
129134
t.start()
130135

131136
def _on_stop(self):
132-
# currently we don't have a cooperative stop in the algorithm; inform user
133-
messagebox.showinfo("Stop", "Stop requested, but immediate stop is not implemented. The run will finish current loop.")
137+
# set stop flag; optimizer will stop cooperatively
138+
self.stop_flag.set()
139+
self.stop_btn.config(state=tk.DISABLED)
140+
self.run_btn.config(state=tk.NORMAL)
141+
messagebox.showinfo("Stop", "Stop requested; optimizer will stop shortly.")
134142

135143
def _on_complete(self, best, cost, history):
136144
x = list(range(len(history.get("best_costs", []))))
@@ -147,6 +155,23 @@ def _on_complete(self, best, cost, history):
147155
self.run_btn.config(state=tk.NORMAL)
148156
self.stop_btn.config(state=tk.DISABLED)
149157

158+
def _update_plot_partial(self, step: int, best_cost: float):
159+
# Append a new point to plot (x=step, y=best_cost)
160+
# We'll redraw full plot for simplicity
161+
line_x = list(range(len(self.ax.lines[0].get_xdata()) + 1)) if self.ax.lines else [step]
162+
if self.ax.lines:
163+
ydata = list(self.ax.lines[0].get_ydata())
164+
ydata.append(best_cost)
165+
else:
166+
ydata = [best_cost]
167+
self.ax.clear()
168+
self.ax.plot(line_x, ydata, label="best_cost")
169+
self.ax.set_xlabel("Iterations")
170+
self.ax.set_ylabel("Best cost")
171+
self.ax.grid(True)
172+
self.ax.legend()
173+
self.canvas.draw()
174+
150175

151176
def main():
152177
app = SA_GUI()

simulated_annealing/simulated_annealing.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,13 @@ def _accept(self, delta: float, temp: float) -> bool:
6565
prob = 0.0
6666
return random.random() < prob
6767

68-
def optimize(self, max_steps: Optional[int] = None) -> Tuple[List[float], float, dict]:
68+
def optimize(self, max_steps: Optional[int] = None, stop_event: Optional[object] = None, progress_callback: Optional[Callable[[int, float, float], None]] = None) -> Tuple[List[float], float, dict]:
6969
"""Run optimization and return best solution, cost, and history.
7070
71+
New optional args:
72+
- stop_event: a threading.Event-like object. If set, optimization stops early.
73+
- progress_callback: callable(step, best_cost, current_cost) called periodically.
74+
7175
history dict contains: temps, best_costs, current_costs
7276
"""
7377
temp = self.temperature
@@ -81,6 +85,11 @@ def optimize(self, max_steps: Optional[int] = None) -> Tuple[List[float], float,
8185
steps = 0
8286
while temp > self.min_temperature:
8387
for _ in range(self.iterations_per_temp):
88+
# Check stop event
89+
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
90+
self.current = current
91+
return best, best_cost, history
92+
8493
candidate = self._neighbor(current)
8594
candidate_cost = float(self.func(candidate))
8695
delta = candidate_cost - current_cost
@@ -96,6 +105,13 @@ def optimize(self, max_steps: Optional[int] = None) -> Tuple[List[float], float,
96105
history["current_costs"].append(current_cost)
97106

98107
steps += 1
108+
if progress_callback is not None and steps % max(1, self.iterations_per_temp // 10) == 0:
109+
try:
110+
progress_callback(steps, best_cost, current_cost)
111+
except Exception:
112+
# Don't let callback errors stop optimization
113+
pass
114+
99115
if max_steps is not None and steps >= max_steps:
100116
self.current = current
101117
return best, best_cost, history

simulated_annealing/tsp.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import math
2+
import random
3+
from typing import List, Sequence, Tuple
4+
5+
6+
def euclidean_distance(a: Sequence[float], b: Sequence[float]) -> float:
7+
return math.hypot(a[0] - b[0], a[1] - b[1])
8+
9+
10+
def total_distance(tour: Sequence[int], coords: Sequence[Tuple[float, float]]) -> float:
11+
d = 0.0
12+
n = len(tour)
13+
for i in range(n):
14+
a = coords[tour[i]]
15+
b = coords[tour[(i + 1) % n]]
16+
d += euclidean_distance(a, b)
17+
return d
18+
19+
20+
def random_tour(n: int) -> List[int]:
21+
tour = list(range(n))
22+
random.shuffle(tour)
23+
return tour
24+
25+
26+
def neighbor_swap(tour: Sequence[int]) -> List[int]:
27+
# swap two indices
28+
n = len(tour)
29+
i, j = random.sample(range(n), 2)
30+
new = list(tour)
31+
new[i], new[j] = new[j], new[i]
32+
return new
33+
34+
35+
def tour_to_vector(tour: Sequence[int]) -> List[float]:
36+
# Convert permutation to a float vector for generic optimizer compatibility
37+
return [float(i) for i in tour]
38+
39+
40+
def vector_to_tour(vec: Sequence[float]) -> List[int]:
41+
# Convert vector of floats back to a tour by ranking
42+
pairs = list(enumerate(vec))
43+
pairs.sort(key=lambda p: p[1])
44+
return [int(p[0]) for p in pairs]
45+
46+
47+
def make_tsp_cost(coords: Sequence[Tuple[float, float]]):
48+
def cost_from_vector(vec: Sequence[float]) -> float:
49+
# Convert vector to tour and compute total distance
50+
tour = vector_to_tour(vec)
51+
return total_distance(tour, coords)
52+
53+
return cost_from_vector

tests/test_simulated_annealing.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import unittest
2+
from simulated_annealing.simulated_annealing import SimulatedAnnealing
3+
4+
5+
def sphere(x):
6+
return sum(v * v for v in x)
7+
8+
9+
class TestSimulatedAnnealing(unittest.TestCase):
10+
def test_minimize_sphere_1d(self):
11+
sa = SimulatedAnnealing(sphere, [5.0], bounds=[(-10, 10)], temperature=10, cooling_rate=0.9, iterations_per_temp=50)
12+
best, cost, hist = sa.optimize()
13+
# Best should be near 0 with tiny cost
14+
self.assertLess(cost, 1e-2)
15+
16+
def test_stop_event(self):
17+
import threading
18+
stop = threading.Event()
19+
sa = SimulatedAnnealing(sphere, [5.0], bounds=[(-10, 10)], temperature=10, cooling_rate=0.9, iterations_per_temp=1000)
20+
# request stop immediately
21+
stop.set()
22+
best, cost, hist = sa.optimize(stop_event=stop)
23+
# Should return without error
24+
self.assertIsNotNone(best)
25+
26+
27+
if __name__ == '__main__':
28+
unittest.main()

tests/test_tsp.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import unittest
2+
from simulated_annealing.tsp import total_distance, random_tour, euclidean_distance
3+
4+
5+
class TestTSP(unittest.TestCase):
6+
def test_distance_symmetric(self):
7+
a = (0, 0)
8+
b = (3, 4)
9+
d = euclidean_distance(a, b)
10+
self.assertAlmostEqual(d, 5.0)
11+
12+
def test_total_distance_cycle(self):
13+
coords = [(0, 0), (0, 1), (1, 1), (1, 0)]
14+
tour = [0, 1, 2, 3]
15+
d = total_distance(tour, coords)
16+
self.assertGreater(d, 0)
17+
18+
19+
if __name__ == '__main__':
20+
unittest.main()

0 commit comments

Comments
 (0)