Spaces:
Runtime error
Runtime error
Add Gradio interfaces for Operations Research solvers and implement parsing functions
Browse files- Introduced `operations_research_interface.py` with Gradio interfaces for Branch and Bound, Simplex, and Dual Simplex solvers.
- Implemented parsing functions for vectors, matrices, relations, and bounds to handle user input.
- Enhanced `simplex_solver_with_steps.py` to log detailed steps during the Simplex method execution.
- Created comprehensive tests for the new interfaces and parsing functions, ensuring robust error handling and validation.
- app.py +15 -0
- maths/university/linear_algebra_interface.py +0 -1
- maths/university/operations_research/BranchAndBoundSolver.py +42 -35
- maths/university/operations_research/DualSimplexSolver.py +154 -155
- maths/university/operations_research/operations_research_interface.py +481 -0
- maths/university/operations_research/simplex_solver_with_steps.py +27 -25
- maths/university/tests/test_operations_research_interface.py +134 -0
app.py
CHANGED
@@ -26,6 +26,9 @@ from maths.university.linear_algebra_interface import (
|
|
26 |
from maths.university.differential_equations_interface import (
|
27 |
first_order_ode_interface, second_order_ode_interface
|
28 |
)
|
|
|
|
|
|
|
29 |
|
30 |
# Group interfaces by education level
|
31 |
# Note: I'm assuming previous subtasks correctly added GCD, LCM, Polynomial, Quadratic, etc. interfaces to their respective files.
|
@@ -68,6 +71,7 @@ university_interfaces_list = [
|
|
68 |
vector_add_interface, vector_subtract_interface, vector_dot_product_interface,
|
69 |
vector_cross_product_interface, solve_linear_system_interface,
|
70 |
first_order_ode_interface, second_order_ode_interface
|
|
|
71 |
]
|
72 |
university_tab_names = [
|
73 |
"Poly Derivatives", "Poly Integrals",
|
@@ -78,8 +82,19 @@ university_tab_names = [
|
|
78 |
"Vector Add", "Vector Subtract", "Vector Dot Product",
|
79 |
"Vector Cross Product", "Solve Linear System",
|
80 |
"1st Order ODE", "2nd Order ODE"
|
|
|
81 |
]
|
82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
elementary_tab = gr.TabbedInterface(elementary_interfaces_list, elementary_tab_names, title="Elementary School Math")
|
84 |
middleschool_tab = gr.TabbedInterface(middleschool_interfaces_list, middleschool_tab_names, title="Middle School Math")
|
85 |
highschool_tab = gr.TabbedInterface(highschool_interfaces_list, highschool_tab_names, title="High School Math")
|
|
|
26 |
from maths.university.differential_equations_interface import (
|
27 |
first_order_ode_interface, second_order_ode_interface
|
28 |
)
|
29 |
+
from maths.university.operations_research.operations_research_interface import (
|
30 |
+
branch_and_bound_interface, dual_simplex_interface, simplex_solver_interface
|
31 |
+
)
|
32 |
|
33 |
# Group interfaces by education level
|
34 |
# Note: I'm assuming previous subtasks correctly added GCD, LCM, Polynomial, Quadratic, etc. interfaces to their respective files.
|
|
|
71 |
vector_add_interface, vector_subtract_interface, vector_dot_product_interface,
|
72 |
vector_cross_product_interface, solve_linear_system_interface,
|
73 |
first_order_ode_interface, second_order_ode_interface
|
74 |
+
# OR Tab will be added below
|
75 |
]
|
76 |
university_tab_names = [
|
77 |
"Poly Derivatives", "Poly Integrals",
|
|
|
82 |
"Vector Add", "Vector Subtract", "Vector Dot Product",
|
83 |
"Vector Cross Product", "Solve Linear System",
|
84 |
"1st Order ODE", "2nd Order ODE"
|
85 |
+
# "Operations Research" will be added below
|
86 |
]
|
87 |
|
88 |
+
# Operations Research Tab
|
89 |
+
or_interfaces_list = [branch_and_bound_interface, dual_simplex_interface, simplex_solver_interface]
|
90 |
+
or_tab_names = ["Branch & Bound", "Dual Simplex", "Simplex (Steps)"]
|
91 |
+
or_tab = gr.TabbedInterface(or_interfaces_list, or_tab_names, title="Operations Research Solvers")
|
92 |
+
|
93 |
+
# Add OR tab to University interfaces
|
94 |
+
university_interfaces_list.append(or_tab)
|
95 |
+
university_tab_names.append("Operations Research")
|
96 |
+
|
97 |
+
|
98 |
elementary_tab = gr.TabbedInterface(elementary_interfaces_list, elementary_tab_names, title="Elementary School Math")
|
99 |
middleschool_tab = gr.TabbedInterface(middleschool_interfaces_list, middleschool_tab_names, title="Middle School Math")
|
100 |
highschool_tab = gr.TabbedInterface(highschool_interfaces_list, highschool_tab_names, title="High School Math")
|
maths/university/linear_algebra_interface.py
CHANGED
@@ -4,7 +4,6 @@ Gradio Interface for Linear Algebra operations.
|
|
4 |
import gradio as gr
|
5 |
import numpy as np
|
6 |
import json # For parsing matrices and vectors easily
|
7 |
-
from typing import Union
|
8 |
|
9 |
from maths.university.linear_algebra import (
|
10 |
matrix_add, matrix_subtract, matrix_multiply,
|
|
|
4 |
import gradio as gr
|
5 |
import numpy as np
|
6 |
import json # For parsing matrices and vectors easily
|
|
|
7 |
|
8 |
from maths.university.linear_algebra import (
|
9 |
matrix_add, matrix_subtract, matrix_multiply,
|
maths/university/operations_research/BranchAndBoundSolver.py
CHANGED
@@ -56,6 +56,9 @@ class BranchAndBoundSolver:
|
|
56 |
|
57 |
# Set of active nodes
|
58 |
self.active_nodes = set()
|
|
|
|
|
|
|
59 |
|
60 |
def is_integer_feasible(self, x):
|
61 |
"""Check if the solution satisfies integer constraints"""
|
@@ -156,10 +159,11 @@ class BranchAndBoundSolver:
|
|
156 |
def display_steps_table(self):
|
157 |
"""Display the steps in tabular format"""
|
158 |
headers = ["Node", "z", "x", "z*", "x*", "UB", "LB", "Z at end of stage"]
|
159 |
-
|
160 |
|
161 |
def solve(self, verbose=True):
|
162 |
"""Solve the problem using branch and bound"""
|
|
|
163 |
# Initialize bounds
|
164 |
lower_bounds = [0] * self.n
|
165 |
upper_bounds = [None] * self.n # None means unbounded
|
@@ -173,26 +177,27 @@ class BranchAndBoundSolver:
|
|
173 |
node_queue = PriorityQueue()
|
174 |
|
175 |
# Solve the root relaxation
|
176 |
-
|
177 |
x_root, obj_root = self.solve_relaxation(lower_bounds, upper_bounds)
|
178 |
|
179 |
if x_root is None:
|
180 |
-
|
181 |
-
|
|
|
182 |
|
183 |
# Add root node to the graph
|
184 |
root_node = "S0"
|
185 |
self.add_node_to_graph(root_node, obj_root, x_root)
|
186 |
|
187 |
-
|
188 |
-
|
189 |
|
190 |
# Initial upper bound is the root objective
|
191 |
upper_bound = obj_root
|
192 |
|
193 |
# Check if the root solution is already integer-feasible
|
194 |
if self.is_integer_feasible(x_root):
|
195 |
-
|
196 |
self.best_solution = x_root
|
197 |
self.best_objective = obj_root
|
198 |
|
@@ -204,9 +209,10 @@ class BranchAndBoundSolver:
|
|
204 |
f"{upper_bound:.2f}", f"{self.best_objective:.2f}", active_nodes_str
|
205 |
])
|
206 |
|
207 |
-
self.display_steps_table()
|
208 |
-
self.
|
209 |
-
|
|
|
210 |
|
211 |
# Add root node to the queue and active nodes set
|
212 |
priority = -obj_root if self.maximize else obj_root
|
@@ -219,11 +225,11 @@ class BranchAndBoundSolver:
|
|
219 |
x_star_str = "-" if self.best_solution is None else f"({', '.join([f'{x:.2f}' for x in self.best_solution])})"
|
220 |
|
221 |
self.steps_table.append([
|
222 |
-
root_node, f"{obj_root:.2f}", f"({', '.join([f'{x:.2f}' for x in x_root])})",
|
223 |
lb_str, x_star_str, f"{upper_bound:.2f}", lb_str, active_nodes_str
|
224 |
])
|
225 |
|
226 |
-
|
227 |
node_counter = 1
|
228 |
|
229 |
while not node_queue.empty():
|
@@ -231,7 +237,7 @@ class BranchAndBoundSolver:
|
|
231 |
priority, _, node_name, node_lower_bounds, node_upper_bounds = node_queue.get()
|
232 |
self.nodes_explored += 1
|
233 |
|
234 |
-
|
235 |
|
236 |
# Remove from active nodes
|
237 |
self.active_nodes.remove(node_name)
|
@@ -244,13 +250,13 @@ class BranchAndBoundSolver:
|
|
244 |
if branch_var in self.binary_vars:
|
245 |
floor_val = 0
|
246 |
ceil_val = 1
|
247 |
-
|
248 |
-
|
249 |
else:
|
250 |
floor_val = math.floor(branch_val)
|
251 |
ceil_val = math.ceil(branch_val)
|
252 |
-
|
253 |
-
|
254 |
|
255 |
# Process left branch (floor)
|
256 |
left_node = f"S{node_counter}"
|
@@ -278,17 +284,17 @@ class BranchAndBoundSolver:
|
|
278 |
|
279 |
# Process the floor branch
|
280 |
if x_floor is None:
|
281 |
-
|
282 |
else:
|
283 |
-
|
284 |
-
|
285 |
|
286 |
# Check if integer feasible and update best solution if needed
|
287 |
if self.is_integer_feasible(x_floor) and ((self.maximize and obj_floor > self.best_objective) or
|
288 |
(not self.maximize and obj_floor < self.best_objective)):
|
289 |
self.best_solution = x_floor.copy()
|
290 |
self.best_objective = obj_floor
|
291 |
-
|
292 |
|
293 |
# Add to queue if not fathomed
|
294 |
if ((self.maximize and obj_floor > self.best_objective) or
|
@@ -325,17 +331,17 @@ class BranchAndBoundSolver:
|
|
325 |
|
326 |
# Process the ceil branch
|
327 |
if x_ceil is None:
|
328 |
-
|
329 |
else:
|
330 |
-
|
331 |
-
|
332 |
|
333 |
# Check if integer feasible and update best solution if needed
|
334 |
if self.is_integer_feasible(x_ceil) and ((self.maximize and obj_ceil > self.best_objective) or
|
335 |
(not self.maximize and obj_ceil < self.best_objective)):
|
336 |
self.best_solution = x_ceil.copy()
|
337 |
self.best_objective = obj_ceil
|
338 |
-
|
339 |
|
340 |
# Add to queue if not fathomed
|
341 |
if ((self.maximize and obj_ceil > self.best_objective) or
|
@@ -361,24 +367,25 @@ class BranchAndBoundSolver:
|
|
361 |
|
362 |
self.steps_table.append([
|
363 |
node_name,
|
364 |
-
f"{self.graph.nodes[node_name]['obj']:.2f}",
|
365 |
f"({', '.join([f'{x:.2f}' for x in self.graph.nodes[node_name]['x']])})",
|
366 |
lb_str, x_star_str, f"{upper_bound:.2f}", lb_str, active_nodes_str
|
367 |
])
|
368 |
|
369 |
-
|
370 |
-
|
371 |
|
372 |
if self.best_solution is not None:
|
373 |
-
|
374 |
-
|
375 |
else:
|
376 |
-
|
377 |
|
378 |
-
#
|
379 |
-
self.display_steps_table()
|
|
|
380 |
|
381 |
# Visualize the graph
|
382 |
-
self.visualize_graph()
|
383 |
|
384 |
-
return self.best_solution, self.best_objective
|
|
|
56 |
|
57 |
# Set of active nodes
|
58 |
self.active_nodes = set()
|
59 |
+
|
60 |
+
# For logging messages
|
61 |
+
self.log_messages = []
|
62 |
|
63 |
def is_integer_feasible(self, x):
|
64 |
"""Check if the solution satisfies integer constraints"""
|
|
|
159 |
def display_steps_table(self):
|
160 |
"""Display the steps in tabular format"""
|
161 |
headers = ["Node", "z", "x", "z*", "x*", "UB", "LB", "Z at end of stage"]
|
162 |
+
return tabulate(self.steps_table, headers=headers, tablefmt="grid")
|
163 |
|
164 |
def solve(self, verbose=True):
|
165 |
"""Solve the problem using branch and bound"""
|
166 |
+
self.log_messages = [] # Initialize log for this run
|
167 |
# Initialize bounds
|
168 |
lower_bounds = [0] * self.n
|
169 |
upper_bounds = [None] * self.n # None means unbounded
|
|
|
177 |
node_queue = PriorityQueue()
|
178 |
|
179 |
# Solve the root relaxation
|
180 |
+
self.log_messages.append("Step 1: Solving root relaxation (continuous problem)")
|
181 |
x_root, obj_root = self.solve_relaxation(lower_bounds, upper_bounds)
|
182 |
|
183 |
if x_root is None:
|
184 |
+
self.log_messages.append("Root problem infeasible")
|
185 |
+
fig = self.visualize_graph() # Still generate graph even if infeasible at root
|
186 |
+
return None, float('-inf') if self.maximize else float('inf'), self.log_messages, fig
|
187 |
|
188 |
# Add root node to the graph
|
189 |
root_node = "S0"
|
190 |
self.add_node_to_graph(root_node, obj_root, x_root)
|
191 |
|
192 |
+
self.log_messages.append(f"Root relaxation objective: {obj_root:.6f}")
|
193 |
+
self.log_messages.append(f"Root solution: {x_root}")
|
194 |
|
195 |
# Initial upper bound is the root objective
|
196 |
upper_bound = obj_root
|
197 |
|
198 |
# Check if the root solution is already integer-feasible
|
199 |
if self.is_integer_feasible(x_root):
|
200 |
+
self.log_messages.append("Root solution is integer-feasible! No need for branching.")
|
201 |
self.best_solution = x_root
|
202 |
self.best_objective = obj_root
|
203 |
|
|
|
209 |
f"{upper_bound:.2f}", f"{self.best_objective:.2f}", active_nodes_str
|
210 |
])
|
211 |
|
212 |
+
steps_table_string = self.display_steps_table()
|
213 |
+
self.log_messages.append(steps_table_string)
|
214 |
+
fig = self.visualize_graph()
|
215 |
+
return self.best_solution, self.best_objective, self.log_messages, fig
|
216 |
|
217 |
# Add root node to the queue and active nodes set
|
218 |
priority = -obj_root if self.maximize else obj_root
|
|
|
225 |
x_star_str = "-" if self.best_solution is None else f"({', '.join([f'{x:.2f}' for x in self.best_solution])})"
|
226 |
|
227 |
self.steps_table.append([
|
228 |
+
root_node, f"{obj_root:.2f}", f"({', '.join([f'{x:.2f}' for x in x_root])})",
|
229 |
lb_str, x_star_str, f"{upper_bound:.2f}", lb_str, active_nodes_str
|
230 |
])
|
231 |
|
232 |
+
self.log_messages.append("\nStarting branch and bound process:")
|
233 |
node_counter = 1
|
234 |
|
235 |
while not node_queue.empty():
|
|
|
237 |
priority, _, node_name, node_lower_bounds, node_upper_bounds = node_queue.get()
|
238 |
self.nodes_explored += 1
|
239 |
|
240 |
+
self.log_messages.append(f"\nStep {self.nodes_explored + 1}: Exploring node {node_name}")
|
241 |
|
242 |
# Remove from active nodes
|
243 |
self.active_nodes.remove(node_name)
|
|
|
250 |
if branch_var in self.binary_vars:
|
251 |
floor_val = 0
|
252 |
ceil_val = 1
|
253 |
+
self.log_messages.append(f" Branching on binary variable x_{branch_var + 1} with value {branch_val:.6f}")
|
254 |
+
self.log_messages.append(f" Creating two branches: x_{branch_var + 1} = 0 and x_{branch_var + 1} = 1")
|
255 |
else:
|
256 |
floor_val = math.floor(branch_val)
|
257 |
ceil_val = math.ceil(branch_val)
|
258 |
+
self.log_messages.append(f" Branching on variable x_{branch_var + 1} with value {branch_val:.6f}")
|
259 |
+
self.log_messages.append(f" Creating two branches: x_{branch_var + 1} ≤ {floor_val} and x_{branch_var + 1} ≥ {ceil_val}")
|
260 |
|
261 |
# Process left branch (floor)
|
262 |
left_node = f"S{node_counter}"
|
|
|
284 |
|
285 |
# Process the floor branch
|
286 |
if x_floor is None:
|
287 |
+
self.log_messages.append(f" {left_node} is infeasible")
|
288 |
else:
|
289 |
+
self.log_messages.append(f" {left_node} relaxation objective: {obj_floor:.6f}")
|
290 |
+
self.log_messages.append(f" {left_node} solution: {x_floor}")
|
291 |
|
292 |
# Check if integer feasible and update best solution if needed
|
293 |
if self.is_integer_feasible(x_floor) and ((self.maximize and obj_floor > self.best_objective) or
|
294 |
(not self.maximize and obj_floor < self.best_objective)):
|
295 |
self.best_solution = x_floor.copy()
|
296 |
self.best_objective = obj_floor
|
297 |
+
self.log_messages.append(f" Found new best integer solution with objective {self.best_objective:.6f}")
|
298 |
|
299 |
# Add to queue if not fathomed
|
300 |
if ((self.maximize and obj_floor > self.best_objective) or
|
|
|
331 |
|
332 |
# Process the ceil branch
|
333 |
if x_ceil is None:
|
334 |
+
self.log_messages.append(f" {right_node} is infeasible")
|
335 |
else:
|
336 |
+
self.log_messages.append(f" {right_node} relaxation objective: {obj_ceil:.6f}")
|
337 |
+
self.log_messages.append(f" {right_node} solution: {x_ceil}")
|
338 |
|
339 |
# Check if integer feasible and update best solution if needed
|
340 |
if self.is_integer_feasible(x_ceil) and ((self.maximize and obj_ceil > self.best_objective) or
|
341 |
(not self.maximize and obj_ceil < self.best_objective)):
|
342 |
self.best_solution = x_ceil.copy()
|
343 |
self.best_objective = obj_ceil
|
344 |
+
self.log_messages.append(f" Found new best integer solution with objective {self.best_objective:.6f}")
|
345 |
|
346 |
# Add to queue if not fathomed
|
347 |
if ((self.maximize and obj_ceil > self.best_objective) or
|
|
|
367 |
|
368 |
self.steps_table.append([
|
369 |
node_name,
|
370 |
+
f"{self.graph.nodes[node_name]['obj']:.2f}",
|
371 |
f"({', '.join([f'{x:.2f}' for x in self.graph.nodes[node_name]['x']])})",
|
372 |
lb_str, x_star_str, f"{upper_bound:.2f}", lb_str, active_nodes_str
|
373 |
])
|
374 |
|
375 |
+
self.log_messages.append("\nBranch and bound completed!")
|
376 |
+
self.log_messages.append(f"Nodes explored: {self.nodes_explored}")
|
377 |
|
378 |
if self.best_solution is not None:
|
379 |
+
self.log_messages.append(f"Optimal objective: {self.best_objective:.6f}")
|
380 |
+
self.log_messages.append(f"Optimal solution: {self.best_solution}")
|
381 |
else:
|
382 |
+
self.log_messages.append("No feasible integer solution found")
|
383 |
|
384 |
+
# Append steps table string to log
|
385 |
+
steps_table_string = self.display_steps_table()
|
386 |
+
self.log_messages.append(steps_table_string)
|
387 |
|
388 |
# Visualize the graph
|
389 |
+
fig = self.visualize_graph()
|
390 |
|
391 |
+
return self.best_solution, self.best_objective, self.log_messages, fig
|
maths/university/operations_research/DualSimplexSolver.py
CHANGED
@@ -44,6 +44,7 @@ class DualSimplexSolver:
|
|
44 |
self.basic_vars = [] # Indices of basic variables (column index)
|
45 |
self.var_names = [] # Names like 'x1', 's1', etc.
|
46 |
self.is_minimized_problem = False # Flag to adjust final Z
|
|
|
47 |
|
48 |
self._preprocess()
|
49 |
|
@@ -134,26 +135,28 @@ class DualSimplexSolver:
|
|
134 |
# Ensure the initial objective row is dual feasible (non-negative coeffs for Max)
|
135 |
# We rely on the user providing a problem where this holds after conversion.
|
136 |
if np.any(self.tableau[0, 1:-1] < -TOLERANCE):
|
137 |
-
|
138 |
-
|
139 |
# For this implementation, we'll proceed, but it might fail if assumption is violated.
|
140 |
|
141 |
|
142 |
def _print_tableau(self, iteration):
|
143 |
-
"""
|
144 |
-
|
|
|
145 |
header = ["BV"] + ["Z"] + self.var_names + ["RHS"]
|
146 |
-
|
147 |
-
|
148 |
|
149 |
basic_var_map = {idx: name for idx, name in enumerate(self.var_names)}
|
150 |
row_basic_vars = ["Z"] + [basic_var_map.get(bv_idx, f'col{bv_idx}') for bv_idx in self.basic_vars]
|
151 |
|
152 |
for i, row_bv_name in enumerate(row_basic_vars):
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
|
|
157 |
|
158 |
|
159 |
def _find_pivot_row(self):
|
@@ -168,12 +171,12 @@ class DualSimplexSolver:
|
|
168 |
if self.tableau[pivot_row_index, -1] >= -TOLERANCE:
|
169 |
return -1 # Should not happen if np.all check passed, but safety check
|
170 |
|
171 |
-
|
172 |
-
|
173 |
leaving_var_idx = self.basic_vars[pivot_row_index - 1]
|
174 |
leaving_var_name = self.var_names[leaving_var_idx]
|
175 |
-
|
176 |
-
|
177 |
return pivot_row_index
|
178 |
|
179 |
def _find_pivot_col(self, pivot_row_index):
|
@@ -185,10 +188,10 @@ class DualSimplexSolver:
|
|
185 |
min_ratio = float('inf')
|
186 |
pivot_col_index = -1
|
187 |
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
|
193 |
found_negative_coeff = False
|
194 |
for j, coeff in enumerate(pivot_row):
|
@@ -198,35 +201,30 @@ class DualSimplexSolver:
|
|
198 |
if coeff < -TOLERANCE: # Must be strictly negative
|
199 |
found_negative_coeff = True
|
200 |
obj_coeff = objective_row[j]
|
201 |
-
# Ratio calculation: obj_coeff / abs(coeff) or obj_coeff / -coeff
|
202 |
ratio = obj_coeff / (-coeff)
|
203 |
ratios[col_var_index] = ratio
|
204 |
-
|
205 |
|
206 |
-
# Update minimum ratio
|
207 |
if ratio < min_ratio:
|
208 |
min_ratio = ratio
|
209 |
-
pivot_col_index = col_tableau_index
|
210 |
|
211 |
if not found_negative_coeff:
|
212 |
-
|
213 |
-
return -1
|
214 |
|
215 |
-
# Handle potential ties in minimum ratio (choose smallest column index - Bland's rule simplified)
|
216 |
min_ratio_vars = [idx for idx, r in ratios.items() if abs(r - min_ratio) < TOLERANCE]
|
217 |
if len(min_ratio_vars) > 1:
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
print(f" Applying Bland's rule: Choosing variable with smallest index: {self.var_names[pivot_col_index - 1]}.")
|
222 |
elif pivot_col_index != -1:
|
223 |
-
entering_var_name = self.var_names[pivot_col_index - 1]
|
224 |
-
|
225 |
-
|
226 |
else:
|
227 |
-
|
228 |
-
|
229 |
-
return -2 # Error indicator
|
230 |
|
231 |
return pivot_col_index
|
232 |
|
@@ -235,209 +233,210 @@ class DualSimplexSolver:
|
|
235 |
"""Performs the pivot operation."""
|
236 |
pivot_element = self.tableau[pivot_row_index, pivot_col_index]
|
237 |
|
238 |
-
|
239 |
-
|
240 |
|
241 |
if abs(pivot_element) < TOLERANCE:
|
242 |
-
|
243 |
-
# This might indicate an issue with the problem formulation or numerical instability.
|
244 |
raise ZeroDivisionError("Pivot element is too close to zero.")
|
245 |
|
246 |
-
|
247 |
-
print(f" Normalizing Pivot Row {pivot_row_index} by dividing by {pivot_element:.3f}")
|
248 |
self.tableau[pivot_row_index, :] /= pivot_element
|
249 |
|
250 |
-
|
251 |
-
print(f" Eliminating other entries in Pivot Column {pivot_col_index}:")
|
252 |
for i in range(self.tableau.shape[0]):
|
253 |
if i != pivot_row_index:
|
254 |
factor = self.tableau[i, pivot_col_index]
|
255 |
-
if abs(factor) > TOLERANCE:
|
256 |
-
|
257 |
self.tableau[i, :] -= factor * self.tableau[pivot_row_index, :]
|
258 |
|
259 |
-
# 3. Update basic variables list
|
260 |
-
# The variable corresponding to pivot_col_index becomes basic for pivot_row_index
|
261 |
old_basic_var_index = self.basic_vars[pivot_row_index - 1]
|
262 |
-
new_basic_var_index = pivot_col_index - 1
|
263 |
self.basic_vars[pivot_row_index - 1] = new_basic_var_index
|
264 |
-
|
265 |
|
266 |
|
267 |
def solve(self, use_fallbacks=True):
|
268 |
"""
|
269 |
Executes the Dual Simplex algorithm.
|
270 |
-
|
271 |
-
Args:
|
272 |
-
use_fallbacks (bool): If True, will attempt to use alternative solvers
|
273 |
-
when the dual simplex method encounters issues
|
274 |
-
|
275 |
Returns:
|
276 |
-
tuple: (
|
277 |
-
or a dictionary of results if fallback solvers were used
|
278 |
"""
|
279 |
-
|
|
|
|
|
|
|
280 |
if self.tableau is None:
|
281 |
-
|
282 |
-
return
|
283 |
|
284 |
iteration = 0
|
285 |
-
self._print_tableau(iteration)
|
|
|
286 |
|
287 |
-
while iteration < 100:
|
288 |
iteration += 1
|
289 |
|
290 |
-
# 1. Check for Optimality (Primal Feasibility)
|
291 |
pivot_row_index = self._find_pivot_row()
|
292 |
if pivot_row_index == -1:
|
293 |
-
|
294 |
-
|
295 |
-
self._print_results()
|
296 |
-
|
|
|
297 |
|
298 |
-
# 2. Select Entering Variable (Pivot Column)
|
299 |
pivot_col_index = self._find_pivot_col(pivot_row_index)
|
300 |
|
301 |
-
# 3. Check for Primal Infeasibility (Dual Unboundedness)
|
302 |
if pivot_col_index == -1:
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
if use_fallbacks:
|
308 |
-
|
309 |
-
|
310 |
-
|
|
|
311 |
elif pivot_col_index == -2:
|
312 |
-
|
313 |
-
print("\n--- Error during pivot column selection ---")
|
314 |
-
|
315 |
if use_fallbacks:
|
316 |
-
|
317 |
-
|
|
|
318 |
|
319 |
-
# 4. Perform Pivot Operation
|
320 |
try:
|
321 |
self._pivot(pivot_row_index, pivot_col_index)
|
322 |
except ZeroDivisionError as e:
|
323 |
-
|
324 |
-
|
325 |
if use_fallbacks:
|
326 |
-
|
327 |
-
|
|
|
328 |
|
329 |
-
|
330 |
-
self.
|
331 |
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
if use_fallbacks:
|
337 |
-
|
338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
339 |
|
340 |
def _try_fallback_solvers(self, error_type):
|
341 |
"""
|
342 |
-
Tries alternative solvers
|
343 |
-
|
344 |
-
Args:
|
345 |
-
error_type (str): Type of error encountered in the dual simplex method
|
346 |
-
|
347 |
-
Returns:
|
348 |
-
dict: Results from fallback solvers
|
349 |
"""
|
350 |
-
|
351 |
|
352 |
results = {
|
353 |
"error_type": error_type,
|
354 |
-
"dual_simplex_result": None,
|
355 |
"dual_approach_result": None,
|
356 |
"direct_solver_result": None
|
357 |
}
|
358 |
|
359 |
-
|
360 |
-
print("\n=== Attempting to solve via Dual Approach with Complementary Slackness ===")
|
361 |
status, message, primal_sol, dual_sol, obj_val = solve_lp_via_dual(
|
362 |
-
self.objective_type,
|
363 |
-
self.
|
364 |
-
self.original_A,
|
365 |
-
self.original_relations,
|
366 |
-
self.original_b
|
367 |
)
|
368 |
|
369 |
results["dual_approach_result"] = {
|
370 |
-
"status": status,
|
371 |
-
"
|
372 |
-
"primal_solution": primal_sol,
|
373 |
-
"dual_solution": dual_sol,
|
374 |
-
"objective_value": obj_val
|
375 |
}
|
|
|
|
|
|
|
|
|
376 |
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
# If that fails, try direct method (most robust)
|
383 |
-
print("\n=== Attempting direct solution using SciPy's linprog solver ===")
|
384 |
-
status, message, primal_sol, _, obj_val = solve_primal_directly(
|
385 |
-
self.objective_type,
|
386 |
-
self.original_c,
|
387 |
-
self.original_A,
|
388 |
-
self.original_relations,
|
389 |
-
self.original_b
|
390 |
)
|
391 |
|
392 |
results["direct_solver_result"] = {
|
393 |
-
"status":
|
394 |
-
"
|
395 |
-
"primal_solution": primal_sol,
|
396 |
-
"objective_value": obj_val
|
397 |
}
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
return results
|
404 |
|
405 |
def _print_results(self):
|
406 |
-
"""
|
407 |
-
|
408 |
-
|
|
|
|
|
409 |
|
410 |
-
# Objective Value
|
411 |
final_obj_value = self.tableau[0, -1]
|
|
|
412 |
if self.is_minimized_problem:
|
413 |
-
final_obj_value = -final_obj_value
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
|
418 |
-
|
419 |
-
solution = {}
|
420 |
num_total_vars = len(self.var_names)
|
421 |
final_solution_vector = np.zeros(num_total_vars)
|
422 |
|
423 |
for i, basis_col_idx in enumerate(self.basic_vars):
|
424 |
-
# basis_col_idx is the index in the var_names list
|
425 |
-
# The corresponding tableau row is i + 1
|
426 |
final_solution_vector[basis_col_idx] = self.tableau[i + 1, -1]
|
427 |
|
428 |
-
print("Optimal Variable Values:")
|
429 |
for i in range(self.num_original_vars):
|
430 |
var_name = self.var_names[i]
|
431 |
value = final_solution_vector[i]
|
432 |
-
|
433 |
-
solution[var_name] = value
|
434 |
|
435 |
-
|
436 |
-
print("Slack/Surplus Variable Values:")
|
437 |
for i in range(self.num_original_vars, num_total_vars):
|
438 |
var_name = self.var_names[i]
|
439 |
value = final_solution_vector[i]
|
440 |
-
# Only print non-zero slacks for brevity, or all if needed
|
441 |
if abs(value) > TOLERANCE:
|
442 |
-
|
|
|
|
|
|
|
|
|
|
|
443 |
|
|
|
44 |
self.basic_vars = [] # Indices of basic variables (column index)
|
45 |
self.var_names = [] # Names like 'x1', 's1', etc.
|
46 |
self.is_minimized_problem = False # Flag to adjust final Z
|
47 |
+
self.log_messages = []
|
48 |
|
49 |
self._preprocess()
|
50 |
|
|
|
135 |
# Ensure the initial objective row is dual feasible (non-negative coeffs for Max)
|
136 |
# We rely on the user providing a problem where this holds after conversion.
|
137 |
if np.any(self.tableau[0, 1:-1] < -TOLERANCE):
|
138 |
+
self.log_messages.append("\nWarning: Initial tableau is not dual feasible (objective row has negative coefficients).")
|
139 |
+
self.log_messages.append("The standard Dual Simplex method might not apply directly or may require Phase I.")
|
140 |
# For this implementation, we'll proceed, but it might fail if assumption is violated.
|
141 |
|
142 |
|
143 |
def _print_tableau(self, iteration):
|
144 |
+
"""Formats the current state of the tableau into a string."""
|
145 |
+
tableau_str_lines = []
|
146 |
+
tableau_str_lines.append(f"\n--- Iteration {iteration} ---")
|
147 |
header = ["BV"] + ["Z"] + self.var_names + ["RHS"]
|
148 |
+
tableau_str_lines.append(" ".join(f"{h:>8}" for h in header))
|
149 |
+
tableau_str_lines.append("-" * (len(header) * 9))
|
150 |
|
151 |
basic_var_map = {idx: name for idx, name in enumerate(self.var_names)}
|
152 |
row_basic_vars = ["Z"] + [basic_var_map.get(bv_idx, f'col{bv_idx}') for bv_idx in self.basic_vars]
|
153 |
|
154 |
for i, row_bv_name in enumerate(row_basic_vars):
|
155 |
+
row_str_parts = [f"{row_bv_name:>8}"]
|
156 |
+
row_str_parts.extend([f"{val: >8.3f}" for val in self.tableau[i]])
|
157 |
+
tableau_str_lines.append(" ".join(row_str_parts))
|
158 |
+
tableau_str_lines.append("-" * (len(header) * 9))
|
159 |
+
return "\n".join(tableau_str_lines)
|
160 |
|
161 |
|
162 |
def _find_pivot_row(self):
|
|
|
171 |
if self.tableau[pivot_row_index, -1] >= -TOLERANCE:
|
172 |
return -1 # Should not happen if np.all check passed, but safety check
|
173 |
|
174 |
+
self.log_messages.append(f"\nStep: Select Pivot Row (Leaving Variable)")
|
175 |
+
self.log_messages.append(f" RHS values (b): {rhs_values}")
|
176 |
leaving_var_idx = self.basic_vars[pivot_row_index - 1]
|
177 |
leaving_var_name = self.var_names[leaving_var_idx]
|
178 |
+
self.log_messages.append(f" Most negative RHS is {self.tableau[pivot_row_index, -1]:.3f} in Row {pivot_row_index} (Basic Var: {leaving_var_name}).")
|
179 |
+
self.log_messages.append(f" Leaving Variable: {leaving_var_name} (Row {pivot_row_index})")
|
180 |
return pivot_row_index
|
181 |
|
182 |
def _find_pivot_col(self, pivot_row_index):
|
|
|
188 |
min_ratio = float('inf')
|
189 |
pivot_col_index = -1
|
190 |
|
191 |
+
self.log_messages.append(f"\nStep: Select Pivot Column (Entering Variable) using Ratio Test")
|
192 |
+
self.log_messages.append(f" Pivot Row (Row {pivot_row_index}) coefficients (excluding Z, RHS): {pivot_row}")
|
193 |
+
self.log_messages.append(f" Objective Row coefficients (excluding Z, RHS): {objective_row}")
|
194 |
+
self.log_messages.append(f" Calculating ratios = ObjCoeff / abs(PivotRowCoeff) for PivotRowCoeff < 0:")
|
195 |
|
196 |
found_negative_coeff = False
|
197 |
for j, coeff in enumerate(pivot_row):
|
|
|
201 |
if coeff < -TOLERANCE: # Must be strictly negative
|
202 |
found_negative_coeff = True
|
203 |
obj_coeff = objective_row[j]
|
|
|
204 |
ratio = obj_coeff / (-coeff)
|
205 |
ratios[col_var_index] = ratio
|
206 |
+
self.log_messages.append(f" Var {self.var_names[col_var_index]} (Col {col_tableau_index}): Coeff={coeff:.3f}, ObjCoeff={obj_coeff:.3f}, Ratio = {obj_coeff:.3f} / {-coeff:.3f} = {ratio:.3f}")
|
207 |
|
|
|
208 |
if ratio < min_ratio:
|
209 |
min_ratio = ratio
|
210 |
+
pivot_col_index = col_tableau_index
|
211 |
|
212 |
if not found_negative_coeff:
|
213 |
+
self.log_messages.append(" No negative coefficients found in the pivot row.")
|
214 |
+
return -1
|
215 |
|
|
|
216 |
min_ratio_vars = [idx for idx, r in ratios.items() if abs(r - min_ratio) < TOLERANCE]
|
217 |
if len(min_ratio_vars) > 1:
|
218 |
+
self.log_messages.append(f" Tie detected for minimum ratio ({min_ratio:.3f}) among variables: {[self.var_names[idx] for idx in min_ratio_vars]}.")
|
219 |
+
pivot_col_index = min(min_ratio_vars) + 1
|
220 |
+
self.log_messages.append(f" Applying Bland's rule: Choosing variable with smallest index: {self.var_names[pivot_col_index - 1]}.")
|
|
|
221 |
elif pivot_col_index != -1:
|
222 |
+
entering_var_name = self.var_names[pivot_col_index - 1]
|
223 |
+
self.log_messages.append(f" Minimum ratio is {min_ratio:.3f} for variable {entering_var_name} (Column {pivot_col_index}).")
|
224 |
+
self.log_messages.append(f" Entering Variable: {entering_var_name} (Column {pivot_col_index})")
|
225 |
else:
|
226 |
+
self.log_messages.append("Error in ratio calculation or tie-breaking.")
|
227 |
+
return -2
|
|
|
228 |
|
229 |
return pivot_col_index
|
230 |
|
|
|
233 |
"""Performs the pivot operation."""
|
234 |
pivot_element = self.tableau[pivot_row_index, pivot_col_index]
|
235 |
|
236 |
+
self.log_messages.append(f"\nStep: Pivot Operation")
|
237 |
+
self.log_messages.append(f" Pivot Element: {pivot_element:.3f} at (Row {pivot_row_index}, Col {pivot_col_index})")
|
238 |
|
239 |
if abs(pivot_element) < TOLERANCE:
|
240 |
+
self.log_messages.append("Error: Pivot element is zero. Cannot proceed.")
|
|
|
241 |
raise ZeroDivisionError("Pivot element is too close to zero.")
|
242 |
|
243 |
+
self.log_messages.append(f" Normalizing Pivot Row {pivot_row_index} by dividing by {pivot_element:.3f}")
|
|
|
244 |
self.tableau[pivot_row_index, :] /= pivot_element
|
245 |
|
246 |
+
self.log_messages.append(f" Eliminating other entries in Pivot Column {pivot_col_index}:")
|
|
|
247 |
for i in range(self.tableau.shape[0]):
|
248 |
if i != pivot_row_index:
|
249 |
factor = self.tableau[i, pivot_col_index]
|
250 |
+
if abs(factor) > TOLERANCE:
|
251 |
+
self.log_messages.append(f" Row {i} = Row {i} - ({factor:.3f}) * (New Row {pivot_row_index})")
|
252 |
self.tableau[i, :] -= factor * self.tableau[pivot_row_index, :]
|
253 |
|
|
|
|
|
254 |
old_basic_var_index = self.basic_vars[pivot_row_index - 1]
|
255 |
+
new_basic_var_index = pivot_col_index - 1
|
256 |
self.basic_vars[pivot_row_index - 1] = new_basic_var_index
|
257 |
+
self.log_messages.append(f" Updating Basic Variables: {self.var_names[new_basic_var_index]} replaces {self.var_names[old_basic_var_index]} in the basis for Row {pivot_row_index}.")
|
258 |
|
259 |
|
260 |
def solve(self, use_fallbacks=True):
|
261 |
"""
|
262 |
Executes the Dual Simplex algorithm.
|
|
|
|
|
|
|
|
|
|
|
263 |
Returns:
|
264 |
+
tuple: (final_solution_str, final_objective_str, log_messages, is_fallback_used_str)
|
|
|
265 |
"""
|
266 |
+
self.log_messages = [] # Clear log for this run
|
267 |
+
self.log_messages.append("--- Starting Dual Simplex Method ---")
|
268 |
+
is_fallback_used_str = "No"
|
269 |
+
|
270 |
if self.tableau is None:
|
271 |
+
self.log_messages.append("Error: Tableau not initialized.")
|
272 |
+
return "Error", "Tableau not initialized", self.log_messages, is_fallback_used_str
|
273 |
|
274 |
iteration = 0
|
275 |
+
tableau_str = self._print_tableau(iteration)
|
276 |
+
self.log_messages.append(tableau_str)
|
277 |
|
278 |
+
while iteration < 100:
|
279 |
iteration += 1
|
280 |
|
|
|
281 |
pivot_row_index = self._find_pivot_row()
|
282 |
if pivot_row_index == -1:
|
283 |
+
self.log_messages.append("\n--- Optimal Solution Found ---")
|
284 |
+
self.log_messages.append(" All RHS values are non-negative.")
|
285 |
+
objective_str, solution_details_str = self._print_results()
|
286 |
+
# _print_results already appends to log, so just return them
|
287 |
+
return solution_details_str, objective_str, self.log_messages, is_fallback_used_str
|
288 |
|
|
|
289 |
pivot_col_index = self._find_pivot_col(pivot_row_index)
|
290 |
|
|
|
291 |
if pivot_col_index == -1:
|
292 |
+
self.log_messages.append("\n--- Primal Problem Infeasible ---")
|
293 |
+
self.log_messages.append(f" All coefficients in Pivot Row {pivot_row_index} are non-negative, but RHS is negative.")
|
294 |
+
self.log_messages.append(" The dual problem is unbounded, implying the primal problem has no feasible solution.")
|
|
|
295 |
if use_fallbacks:
|
296 |
+
is_fallback_used_str = "Yes"
|
297 |
+
return self._handle_fallback_results("primal_infeasible")
|
298 |
+
return "Infeasible", "N/A", self.log_messages, is_fallback_used_str
|
299 |
+
|
300 |
elif pivot_col_index == -2:
|
301 |
+
self.log_messages.append("\n--- Error during pivot column selection ---")
|
|
|
|
|
302 |
if use_fallbacks:
|
303 |
+
is_fallback_used_str = "Yes"
|
304 |
+
return self._handle_fallback_results("pivot_error")
|
305 |
+
return "Error", "Pivot selection error", self.log_messages, is_fallback_used_str
|
306 |
|
|
|
307 |
try:
|
308 |
self._pivot(pivot_row_index, pivot_col_index)
|
309 |
except ZeroDivisionError as e:
|
310 |
+
self.log_messages.append(f"\n--- Error during pivot operation: {e} ---")
|
|
|
311 |
if use_fallbacks:
|
312 |
+
is_fallback_used_str = "Yes"
|
313 |
+
return self._handle_fallback_results("numerical_instability")
|
314 |
+
return "Error", "Numerical instability", self.log_messages, is_fallback_used_str
|
315 |
|
316 |
+
tableau_str = self._print_tableau(iteration)
|
317 |
+
self.log_messages.append(tableau_str)
|
318 |
|
319 |
+
self.log_messages.append("\n--- Maximum Iterations Reached ---")
|
320 |
+
self.log_messages.append(" The algorithm did not converge within the iteration limit.")
|
321 |
+
self.log_messages.append(" This might indicate cycling or a very large problem.")
|
|
|
322 |
if use_fallbacks:
|
323 |
+
is_fallback_used_str = "Yes"
|
324 |
+
return self._handle_fallback_results("iteration_limit")
|
325 |
+
return "Error", "Max iterations reached", self.log_messages, is_fallback_used_str
|
326 |
+
|
327 |
+
def _handle_fallback_results(self, error_type_for_primary_solver):
|
328 |
+
""" Helper to process results from _try_fallback_solvers and structure return for solve() """
|
329 |
+
fallback_results = self._try_fallback_solvers(error_type_for_primary_solver)
|
330 |
+
|
331 |
+
final_solution_str = "Fallback attempted."
|
332 |
+
final_objective_str = "N/A"
|
333 |
+
is_fallback_used_str = f"Yes, due to {error_type_for_primary_solver}."
|
334 |
+
|
335 |
+
# Check dual_approach_result first
|
336 |
+
if fallback_results.get("dual_approach_result"):
|
337 |
+
res = fallback_results["dual_approach_result"]
|
338 |
+
is_fallback_used_str += f" Dual Approach: {res['message']}."
|
339 |
+
if res["status"] == 0 and res["primal_solution"] is not None:
|
340 |
+
final_objective_str = f"{res['objective_value']:.6f} (via Dual Approach)"
|
341 |
+
final_solution_str = ", ".join([f"x{i+1}={v:.3f}" for i, v in enumerate(res["primal_solution"])])
|
342 |
+
return final_solution_str, final_objective_str, self.log_messages, is_fallback_used_str
|
343 |
+
|
344 |
+
# Then check direct_solver_result
|
345 |
+
if fallback_results.get("direct_solver_result"):
|
346 |
+
res = fallback_results["direct_solver_result"]
|
347 |
+
is_fallback_used_str += f" Direct Solver: {res['message']}."
|
348 |
+
if res["status"] == 0 and res["primal_solution"] is not None:
|
349 |
+
final_objective_str = f"{res['objective_value']:.6f} (via Direct Solver)"
|
350 |
+
final_solution_str = ", ".join([f"x{i+1}={v:.3f}" for i, v in enumerate(res["primal_solution"])])
|
351 |
+
return final_solution_str, final_objective_str, self.log_messages, is_fallback_used_str
|
352 |
+
|
353 |
+
# If both fallbacks failed or didn't yield a solution
|
354 |
+
final_solution_str = "All solvers failed or problem is infeasible/unbounded."
|
355 |
+
self.log_messages.append(final_solution_str)
|
356 |
+
return final_solution_str, final_objective_str, self.log_messages, is_fallback_used_str
|
357 |
+
|
358 |
|
359 |
def _try_fallback_solvers(self, error_type):
|
360 |
"""
|
361 |
+
Tries alternative solvers. Appends to self.log_messages.
|
362 |
+
Returns dict of results.
|
|
|
|
|
|
|
|
|
|
|
363 |
"""
|
364 |
+
self.log_messages.append(f"\n--- Using Fallback Solvers due to '{error_type}' ---")
|
365 |
|
366 |
results = {
|
367 |
"error_type": error_type,
|
368 |
+
"dual_simplex_result": None, # This would be the state if Dual Simplex had a result
|
369 |
"dual_approach_result": None,
|
370 |
"direct_solver_result": None
|
371 |
}
|
372 |
|
373 |
+
self.log_messages.append("\n=== Attempting to solve via Dual Approach with Complementary Slackness ===")
|
|
|
374 |
status, message, primal_sol, dual_sol, obj_val = solve_lp_via_dual(
|
375 |
+
self.objective_type, self.original_c, self.original_A,
|
376 |
+
self.original_relations, self.original_b
|
|
|
|
|
|
|
377 |
)
|
378 |
|
379 |
results["dual_approach_result"] = {
|
380 |
+
"status": status, "message": message, "primal_solution": primal_sol,
|
381 |
+
"dual_solution": dual_sol, "objective_value": obj_val
|
|
|
|
|
|
|
382 |
}
|
383 |
+
self.log_messages.append(f"Dual Approach Result: {message}")
|
384 |
+
if status == 0 and primal_sol is not None:
|
385 |
+
self.log_messages.append(f"Objective Value (Dual Approach): {obj_val}")
|
386 |
+
# No early return, let solve() decide based on this dict
|
387 |
|
388 |
+
self.log_messages.append("\n=== Attempting direct solution using SciPy's linprog solver ===")
|
389 |
+
status_direct, message_direct, primal_sol_direct, _, obj_val_direct = solve_primal_directly(
|
390 |
+
self.objective_type, self.original_c, self.original_A,
|
391 |
+
self.original_relations, self.original_b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
392 |
)
|
393 |
|
394 |
results["direct_solver_result"] = {
|
395 |
+
"status": status_direct, "message": message_direct,
|
396 |
+
"primal_solution": primal_sol_direct, "objective_value": obj_val_direct
|
|
|
|
|
397 |
}
|
398 |
+
self.log_messages.append(f"Direct Solver Result: {message_direct}")
|
399 |
+
if status_direct == 0 and primal_sol_direct is not None:
|
400 |
+
self.log_messages.append(f"Objective Value (Direct Solver): {obj_val_direct}")
|
401 |
+
|
|
|
402 |
return results
|
403 |
|
404 |
def _print_results(self):
|
405 |
+
"""Formats the final solution into strings and appends to log_messages."""
|
406 |
+
self.log_messages.append("\n--- Final Solution (from Dual Simplex Tableau) ---")
|
407 |
+
|
408 |
+
tableau_str = self._print_tableau("Final") # This method now returns a string
|
409 |
+
self.log_messages.append(tableau_str)
|
410 |
|
|
|
411 |
final_obj_value = self.tableau[0, -1]
|
412 |
+
obj_type_str = "Min Z" if self.is_minimized_problem else "Max Z"
|
413 |
if self.is_minimized_problem:
|
414 |
+
final_obj_value = -final_obj_value
|
415 |
+
|
416 |
+
objective_str = f"Optimal Objective Value ({obj_type_str}): {final_obj_value:.6f}"
|
417 |
+
self.log_messages.append(objective_str)
|
418 |
|
419 |
+
solution_details_parts = ["Optimal Variable Values:"]
|
|
|
420 |
num_total_vars = len(self.var_names)
|
421 |
final_solution_vector = np.zeros(num_total_vars)
|
422 |
|
423 |
for i, basis_col_idx in enumerate(self.basic_vars):
|
|
|
|
|
424 |
final_solution_vector[basis_col_idx] = self.tableau[i + 1, -1]
|
425 |
|
|
|
426 |
for i in range(self.num_original_vars):
|
427 |
var_name = self.var_names[i]
|
428 |
value = final_solution_vector[i]
|
429 |
+
solution_details_parts.append(f" {var_name}: {value:.6f}")
|
|
|
430 |
|
431 |
+
solution_details_parts.append("Slack/Surplus Variable Values:")
|
|
|
432 |
for i in range(self.num_original_vars, num_total_vars):
|
433 |
var_name = self.var_names[i]
|
434 |
value = final_solution_vector[i]
|
|
|
435 |
if abs(value) > TOLERANCE:
|
436 |
+
solution_details_parts.append(f" {var_name}: {value:.6f}")
|
437 |
+
|
438 |
+
solution_details_str = "\n".join(solution_details_parts)
|
439 |
+
self.log_messages.append(solution_details_str)
|
440 |
+
|
441 |
+
return objective_str, solution_details_str
|
442 |
|
maths/university/operations_research/operations_research_interface.py
ADDED
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Gradio interfaces for Operations Research solvers."""
|
2 |
+
import gradio as gr
|
3 |
+
import numpy as np
|
4 |
+
|
5 |
+
def parse_vector(input_str: str, dtype=float) -> list:
|
6 |
+
"""Parses a comma-separated string into a list of numbers."""
|
7 |
+
if not input_str:
|
8 |
+
return []
|
9 |
+
try:
|
10 |
+
return [dtype(x.strip()) for x in input_str.split(',')]
|
11 |
+
except ValueError:
|
12 |
+
gr.Warning(f"Could not parse vector: '{input_str}'. Please use comma-separated numbers (e.g., '1,2,3.5').")
|
13 |
+
return []
|
14 |
+
|
15 |
+
def parse_matrix(input_str: str) -> np.ndarray:
|
16 |
+
"""Parses a string (rows separated by semicolons, elements by commas) into a NumPy array."""
|
17 |
+
if not input_str:
|
18 |
+
return np.array([])
|
19 |
+
try:
|
20 |
+
rows = input_str.split(';')
|
21 |
+
matrix = []
|
22 |
+
num_cols = -1
|
23 |
+
for i, row_str in enumerate(rows):
|
24 |
+
if not row_str.strip(): continue # Allow empty rows if they result from trailing semicolons
|
25 |
+
|
26 |
+
row = [float(x.strip()) for x in row_str.split(',')]
|
27 |
+
if num_cols == -1:
|
28 |
+
num_cols = len(row)
|
29 |
+
elif len(row) != num_cols:
|
30 |
+
raise ValueError(f"Row {i+1} has {len(row)} elements, expected {num_cols}.")
|
31 |
+
matrix.append(row)
|
32 |
+
|
33 |
+
if not matrix: # If all rows were empty or input_str was just ';'
|
34 |
+
return np.array([])
|
35 |
+
|
36 |
+
return np.array(matrix)
|
37 |
+
except ValueError as e:
|
38 |
+
gr.Warning(f"Could not parse matrix: '{input_str}'. Error: {e}. Expected format: '1,2;3,4'. Ensure all rows have the same number of columns.")
|
39 |
+
return np.array([])
|
40 |
+
|
41 |
+
def parse_relations(input_str: str) -> list[str]:
|
42 |
+
"""Parses a comma-separated string of relations into a list of strings."""
|
43 |
+
if not input_str:
|
44 |
+
return []
|
45 |
+
try:
|
46 |
+
relations = [r.strip() for r in input_str.split(',')]
|
47 |
+
valid_relations = {"<=", ">=", "="}
|
48 |
+
if not all(r in valid_relations for r in relations):
|
49 |
+
invalid_rels = [r for r in relations if r not in valid_relations]
|
50 |
+
gr.Warning(f"Invalid relation(s) found: {', '.join(invalid_rels)}. Allowed relations are: '<=', '>=', '='.")
|
51 |
+
return []
|
52 |
+
return relations
|
53 |
+
except Exception as e: # Catch any other unexpected errors during parsing
|
54 |
+
gr.Warning(f"Error parsing relations: '{input_str}'. Error: {e}")
|
55 |
+
return []
|
56 |
+
|
57 |
+
def parse_bounds(input_str: str) -> list[tuple]:
|
58 |
+
"""
|
59 |
+
Parses a string representing variable bounds into a list of tuples.
|
60 |
+
Format: "lower1,upper1; lower2,upper2; ..." (e.g., "0,None; 0,10; None,None")
|
61 |
+
'None' (case-insensitive) is used for no bound.
|
62 |
+
"""
|
63 |
+
if not input_str:
|
64 |
+
return []
|
65 |
+
bounds_list = []
|
66 |
+
try:
|
67 |
+
pairs = input_str.split(';')
|
68 |
+
for i, pair_str in enumerate(pairs):
|
69 |
+
if not pair_str.strip(): continue # Allow for trailing semicolons or empty entries
|
70 |
+
|
71 |
+
parts = pair_str.split(',')
|
72 |
+
if len(parts) != 2:
|
73 |
+
raise ValueError(f"Bound pair '{pair_str}' (entry {i+1}) does not have two elements. Expected format 'lower,upper'.")
|
74 |
+
|
75 |
+
lower_str, upper_str = parts[0].strip().lower(), parts[1].strip().lower()
|
76 |
+
|
77 |
+
lower = None if lower_str == 'none' else float(lower_str)
|
78 |
+
upper = None if upper_str == 'none' else float(upper_str)
|
79 |
+
|
80 |
+
if lower is not None and upper is not None and lower > upper:
|
81 |
+
raise ValueError(f"Lower bound {lower} cannot be greater than upper bound {upper} for pair '{pair_str}' (entry {i+1}).")
|
82 |
+
|
83 |
+
bounds_list.append((lower, upper))
|
84 |
+
return bounds_list
|
85 |
+
except ValueError as e:
|
86 |
+
gr.Warning(f"Could not parse bounds: '{input_str}'. Error: {e}. Expected format: 'lower1,upper1; lower2,upper2; ...' e.g., '0,None; 0,10'. Use 'None' for no bound.")
|
87 |
+
return []
|
88 |
+
except Exception as e: # Catch any other unexpected parsing errors
|
89 |
+
gr.Warning(f"Unexpected error parsing bounds: '{input_str}'. Error: {e}")
|
90 |
+
return []
|
91 |
+
|
92 |
+
# --- Branch and Bound Interface ---
|
93 |
+
from .BranchAndBoundSolver import BranchAndBoundSolver
|
94 |
+
|
95 |
+
def solve_branch_and_bound_interface(c_str, A_str, b_str, integer_vars_str, binary_vars_str, maximize_bool):
|
96 |
+
"""
|
97 |
+
Wrapper function to connect BranchAndBoundSolver with Gradio interface.
|
98 |
+
"""
|
99 |
+
log_messages = []
|
100 |
+
|
101 |
+
c = parse_vector(c_str)
|
102 |
+
if not c:
|
103 |
+
log_messages.append("Error: Objective coefficients (c) could not be parsed or are empty.")
|
104 |
+
return "Error parsing c", "Error parsing c", "\n".join(log_messages), None
|
105 |
+
|
106 |
+
A = parse_matrix(A_str)
|
107 |
+
if A.size == 0: # Check if array is empty
|
108 |
+
log_messages.append("Error: Constraint matrix (A) could not be parsed or is empty.")
|
109 |
+
return "Error parsing A", "Error parsing A", "\n".join(log_messages), None
|
110 |
+
|
111 |
+
b = parse_vector(b_str)
|
112 |
+
if not b:
|
113 |
+
log_messages.append("Error: Constraint bounds (b) could not be parsed or are empty.")
|
114 |
+
return "Error parsing b", "Error parsing b", "\n".join(log_messages), None
|
115 |
+
|
116 |
+
# Validate dimensions
|
117 |
+
if A.shape[0] != len(b):
|
118 |
+
log_messages.append(f"Error: Number of rows in A ({A.shape[0]}) does not match number of elements in b ({len(b)}).")
|
119 |
+
return "Dimension mismatch A vs b", "Dimension mismatch A vs b", "\n".join(log_messages), None
|
120 |
+
if A.shape[1] != len(c):
|
121 |
+
log_messages.append(f"Error: Number of columns in A ({A.shape[1]}) does not match number of elements in c ({len(c)}).")
|
122 |
+
return "Dimension mismatch A vs c", "Dimension mismatch A vs c", "\n".join(log_messages), None
|
123 |
+
|
124 |
+
integer_vars = []
|
125 |
+
if integer_vars_str:
|
126 |
+
try:
|
127 |
+
integer_vars = [int(x.strip()) for x in integer_vars_str.split(',')]
|
128 |
+
if not all(0 <= i < len(c) for i in integer_vars):
|
129 |
+
raise ValueError("Integer variable indices out of bounds.")
|
130 |
+
except ValueError as e:
|
131 |
+
log_messages.append(f"Error parsing integer variable indices: {e}. Please use comma-separated 0-indexed integers (e.g., '0,1').")
|
132 |
+
return "Error parsing integer_vars", "Error parsing integer_vars", "\n".join(log_messages), None
|
133 |
+
|
134 |
+
binary_vars = []
|
135 |
+
if binary_vars_str:
|
136 |
+
try:
|
137 |
+
binary_vars = [int(x.strip()) for x in binary_vars_str.split(',')]
|
138 |
+
if not all(0 <= i < len(c) for i in binary_vars):
|
139 |
+
raise ValueError("Binary variable indices out of bounds.")
|
140 |
+
except ValueError as e:
|
141 |
+
log_messages.append(f"Error parsing binary variable indices: {e}. Please use comma-separated 0-indexed integers (e.g., '0,1').")
|
142 |
+
return "Error parsing binary_vars", "Error parsing binary_vars", "\n".join(log_messages), None
|
143 |
+
|
144 |
+
# Solver expects None if no specific integer_vars are given (meaning all are integer)
|
145 |
+
# However, our current B&B implementation defaults integer_vars to all if None.
|
146 |
+
# For clarity with user input (where empty means "no specific integer vars beyond binary"),
|
147 |
+
# we'll pass the list. If it's empty, and binary_vars is also empty, it implies a continuous LP for B&B,
|
148 |
+
# or if binary_vars is not empty, those will be treated as integer.
|
149 |
+
# The B&B solver correctly adds binary_vars to its internal integer_vars list.
|
150 |
+
|
151 |
+
log_messages.append("Inputs parsed successfully. Starting solver...")
|
152 |
+
|
153 |
+
try:
|
154 |
+
solver = BranchAndBoundSolver(c=np.array(c), A=A, b=np.array(b),
|
155 |
+
integer_vars=integer_vars if integer_vars else None, # Pass None if list is empty for solver's default logic
|
156 |
+
binary_vars=binary_vars if binary_vars else None, # Pass None if list is empty
|
157 |
+
maximize=maximize_bool)
|
158 |
+
|
159 |
+
best_solution, best_objective, solver_log, fig = solver.solve()
|
160 |
+
|
161 |
+
log_messages.extend(solver_log) # Add solver's internal log
|
162 |
+
|
163 |
+
if best_solution is not None:
|
164 |
+
solution_str = ", ".join([f"{val:.3f}" for val in best_solution])
|
165 |
+
objective_str = f"{best_objective:.3f}"
|
166 |
+
else:
|
167 |
+
solution_str = "No feasible integer solution found."
|
168 |
+
objective_str = "N/A"
|
169 |
+
if best_objective == float('-inf') or best_objective == float('inf'): # Check if it was due to infeasibility
|
170 |
+
objective_str = "Infeasible or unbounded"
|
171 |
+
|
172 |
+
|
173 |
+
return solution_str, objective_str, "\n".join(log_messages), fig
|
174 |
+
|
175 |
+
except Exception as e:
|
176 |
+
gr.Error(f"An error occurred during solving: {e}")
|
177 |
+
log_messages.append(f"Runtime error: {e}")
|
178 |
+
return "Solver error", "Solver error", "\n".join(log_messages), None
|
179 |
+
|
180 |
+
|
181 |
+
branch_and_bound_interface = gr.Interface(
|
182 |
+
fn=solve_branch_and_bound_interface,
|
183 |
+
inputs=[
|
184 |
+
gr.Textbox(label="Objective Coefficients (c)", info="Comma-separated, e.g., 3,2"),
|
185 |
+
gr.Textbox(label="Constraint Matrix (A)", info="Rows separated by ';', elements by ',', e.g., 2,1; 1,1; 1,0"),
|
186 |
+
gr.Textbox(label="Constraint RHS (b)", info="Comma-separated, e.g., 10,8,3. Must be Ax <= b form."),
|
187 |
+
gr.Textbox(label="Integer Variable Indices (optional)", info="Comma-separated, 0-indexed. If empty and no binary vars, all vars are continuous for B&B (effectively LP). If empty but binary vars exist, only binary are integer. If 'all', all vars are integer."), # Adjusted info
|
188 |
+
gr.Textbox(label="Binary Variable Indices (optional)", info="Comma-separated, 0-indexed, e.g., 0,1"),
|
189 |
+
gr.Checkbox(label="Maximize?", value=True)
|
190 |
+
],
|
191 |
+
outputs=[
|
192 |
+
gr.Textbox(label="Optimal Solution (x)"),
|
193 |
+
gr.Textbox(label="Optimal Objective Value (z)"),
|
194 |
+
gr.Textbox(label="Solver Log and Steps", lines=15, interactive=False),
|
195 |
+
gr.Plot(label="Branch and Bound Tree")
|
196 |
+
],
|
197 |
+
title="Branch and Bound Solver for Mixed Integer Linear Programs (MILP)",
|
198 |
+
description="Solves MILPs (Ax <= b) using the Branch and Bound method. Specify integer/binary variables or leave empty if not applicable (for pure LP).",
|
199 |
+
examples=[
|
200 |
+
[ # Example 1: Knapsack-like problem (from a common example)
|
201 |
+
"8,11,6,4", # c: Objective (maximize)
|
202 |
+
"5,7,4,3; 1,1,1,1", # A: Constraints matrix
|
203 |
+
"14; 4", # b: Constraints RHS
|
204 |
+
"", # integer_vars: all variables are effectively integer due to binary constraint if specified, or by default if integer_vars=None
|
205 |
+
"0,1,2,3", # binary_vars: All variables are binary
|
206 |
+
True # Maximize
|
207 |
+
],
|
208 |
+
[ # Example 2: Simple MILP
|
209 |
+
"3,2,4", # c
|
210 |
+
"1,1,1; 2,1,0", # A
|
211 |
+
"10; 5", # b
|
212 |
+
"0,2", # integer_vars: x0 and x2 are integer
|
213 |
+
"", # binary_vars
|
214 |
+
True # Maximize
|
215 |
+
],
|
216 |
+
[ # Example 3: Minimization problem (from a common example)
|
217 |
+
"3,5", # c (minimize)
|
218 |
+
"-1,0; 0,-1; 3,2", # A (original constraints might be >=, converted to <= by multiplying by -1)
|
219 |
+
"0;0;18", # b (original might be >=0, >=0, <=18. For >=0, we write -x <= 0)
|
220 |
+
"0,1", # integer_vars
|
221 |
+
"", # binary_vars
|
222 |
+
False # Minimize
|
223 |
+
],
|
224 |
+
[ # Example 4: Provided in task description
|
225 |
+
"5,4", #c
|
226 |
+
"1,1;2,0;0,1", #A
|
227 |
+
"5,6,3", #b
|
228 |
+
"0,1", #integer
|
229 |
+
"", #binary
|
230 |
+
True #maximize
|
231 |
+
]
|
232 |
+
],
|
233 |
+
allow_flagging="never"
|
234 |
+
)
|
235 |
+
|
236 |
+
# --- Simplex Solver (with Steps) Interface ---
|
237 |
+
from .simplex_solver_with_steps import simplex_solver_with_steps
|
238 |
+
|
239 |
+
def run_simplex_solver_interface(c_str, A_str, b_str, bounds_str):
|
240 |
+
"""
|
241 |
+
Wrapper function to connect simplex_solver_with_steps with Gradio interface.
|
242 |
+
"""
|
243 |
+
current_log_list = ["Initializing Simplex Solver (with Steps) Interface..."]
|
244 |
+
|
245 |
+
c = parse_vector(c_str)
|
246 |
+
if not c:
|
247 |
+
current_log_list.append("Error: Objective coefficients (c) could not be parsed or are empty.")
|
248 |
+
return "Error parsing c", "Error parsing c", "\n".join(current_log_list)
|
249 |
+
|
250 |
+
A = parse_matrix(A_str)
|
251 |
+
if A.size == 0:
|
252 |
+
current_log_list.append("Error: Constraint matrix (A) could not be parsed or is empty.")
|
253 |
+
return "Error parsing A", "Error parsing A", "\n".join(current_log_list)
|
254 |
+
|
255 |
+
b = parse_vector(b_str)
|
256 |
+
if not b:
|
257 |
+
current_log_list.append("Error: Constraint bounds (b) could not be parsed or are empty.")
|
258 |
+
return "Error parsing b", "Error parsing b", "\n".join(current_log_list)
|
259 |
+
|
260 |
+
variable_bounds = parse_bounds(bounds_str) # This returns a list of tuples or []
|
261 |
+
# parse_bounds includes gr.Warning on failure and returns []
|
262 |
+
if bounds_str and not variable_bounds: # If input was given but parsing failed
|
263 |
+
current_log_list.append("Error: Variable bounds string could not be parsed. Please check format.")
|
264 |
+
# parse_bounds already issues a gr.Warning
|
265 |
+
return "Error parsing var_bounds", "Error parsing var_bounds", "\n".join(current_log_list)
|
266 |
+
|
267 |
+
|
268 |
+
# Dimensional validation
|
269 |
+
if A.shape[0] != len(b):
|
270 |
+
current_log_list.append(f"Dimension mismatch: Number of rows in A ({A.shape[0]}) must equal length of b ({len(b)}).")
|
271 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log_list)
|
272 |
+
if A.shape[1] != len(c):
|
273 |
+
current_log_list.append(f"Dimension mismatch: Number of columns in A ({A.shape[1]}) must equal length of c ({len(c)}).")
|
274 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log_list)
|
275 |
+
if variable_bounds and len(variable_bounds) != len(c):
|
276 |
+
current_log_list.append(f"Dimension mismatch: Number of variable bounds pairs ({len(variable_bounds)}) must equal number of objective coefficients ({len(c)}).")
|
277 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log_list)
|
278 |
+
|
279 |
+
# If bounds_str is empty, parse_bounds returns [], which is fine for the solver if it expects default non-negativity or specific handling.
|
280 |
+
# The simplex_solver_with_steps expects a list of tuples, and an empty list if no specific bounds beyond non-negativity are implied by problem setup.
|
281 |
+
# For this solver, bounds are crucial. If not provided, we should create default non-negative bounds.
|
282 |
+
if not variable_bounds:
|
283 |
+
variable_bounds = [(0, None) for _ in range(len(c))]
|
284 |
+
current_log_list.append("Defaulting to non-negative bounds for all variables as no specific bounds were provided.")
|
285 |
+
|
286 |
+
|
287 |
+
current_log_list.append("Inputs parsed and validated successfully.")
|
288 |
+
|
289 |
+
try:
|
290 |
+
# Ensure inputs are NumPy arrays as expected by some solvers (though this one might be flexible)
|
291 |
+
np_c = np.array(c)
|
292 |
+
np_b = np.array(b)
|
293 |
+
|
294 |
+
# Call the solver
|
295 |
+
# simplex_solver_with_steps returns: steps_log, x, optimal_value
|
296 |
+
steps_log, x_solution, opt_val = simplex_solver_with_steps(np_c, A, np_b, variable_bounds)
|
297 |
+
|
298 |
+
current_log_list.extend(steps_log) # steps_log is already a list of strings
|
299 |
+
|
300 |
+
solution_str = "N/A"
|
301 |
+
objective_str = "N/A"
|
302 |
+
|
303 |
+
if x_solution is not None:
|
304 |
+
solution_str = ", ".join([f"{val:.3f}" for val in x_solution])
|
305 |
+
else: # Check specific messages in log if solution is None
|
306 |
+
if any("Unbounded solution" in msg for msg in steps_log):
|
307 |
+
solution_str = "Unbounded solution"
|
308 |
+
elif any("infeasible" in msg.lower() for msg in steps_log): # More general infeasibility check
|
309 |
+
solution_str = "Infeasible solution"
|
310 |
+
|
311 |
+
|
312 |
+
if opt_val is not None:
|
313 |
+
if opt_val == float('inf') or opt_val == float('-inf'):
|
314 |
+
objective_str = str(opt_val)
|
315 |
+
else:
|
316 |
+
objective_str = f"{opt_val:.3f}"
|
317 |
+
|
318 |
+
return solution_str, objective_str, "\n".join(current_log_list)
|
319 |
+
|
320 |
+
except Exception as e:
|
321 |
+
gr.Error(f"An error occurred during solving with Simplex Method: {e}")
|
322 |
+
current_log_list.append(f"Runtime error in Simplex Method: {e}")
|
323 |
+
return "Solver error", "Solver error", "\n".join(current_log_list)
|
324 |
+
|
325 |
+
simplex_solver_interface = gr.Interface(
|
326 |
+
fn=run_simplex_solver_interface,
|
327 |
+
inputs=[
|
328 |
+
gr.Textbox(label="Objective Coefficients (c)", info="Comma-separated for maximization problem, e.g., 3,5"),
|
329 |
+
gr.Textbox(label="Constraint Matrix (A)", info="Rows separated by ';', elements by ',', e.g., 1,0;0,2;3,2. Assumes Ax <= b."),
|
330 |
+
gr.Textbox(label="Constraint RHS (b)", info="Comma-separated, e.g., 4,12,18"),
|
331 |
+
gr.Textbox(label="Variable Bounds (L,U) per variable", info="Pairs like L1,U1; L2,U2;... Use 'None' for no bound. E.g., '0,None;0,10'. If empty, defaults to non-negative for all variables (0,None).")
|
332 |
+
],
|
333 |
+
outputs=[
|
334 |
+
gr.Textbox(label="Optimal Solution (x)"),
|
335 |
+
gr.Textbox(label="Optimal Objective Value"),
|
336 |
+
gr.Textbox(label="Solver Steps and Tableaus", lines=20, interactive=False)
|
337 |
+
],
|
338 |
+
title="Simplex Method Solver (with Tableau Steps)",
|
339 |
+
description="Solves Linear Programs (maximization, Ax <= b) using the Simplex method and displays detailed tableau iterations. Assumes variables are non-negative if bounds not specified.",
|
340 |
+
examples=[
|
341 |
+
[ # Classic example from many textbooks (e.g., Taha, Operations Research)
|
342 |
+
"3,5", # c (Max Z = 3x1 + 5x2)
|
343 |
+
"1,0;0,2;3,2", # A
|
344 |
+
"4,12,18", # b
|
345 |
+
"0,None;0,None" # x1 >=0, x2 >=0 (explicitly stating non-negativity)
|
346 |
+
],
|
347 |
+
[ # Another common example
|
348 |
+
"5,4", # c (Max Z = 5x1 + 4x2)
|
349 |
+
"6,4;1,2; -1,1;0,1", # A
|
350 |
+
"24,6,1,2", #b
|
351 |
+
"0,None;0,None" # x1,x2 >=0
|
352 |
+
],
|
353 |
+
[ # Example that might be unbounded if not careful or show interesting steps
|
354 |
+
"1,1",
|
355 |
+
"1,-1;-1,1",
|
356 |
+
"1,1",
|
357 |
+
"0,None;0,None" # x1-x2 <=1, -x1+x2 <=1
|
358 |
+
]
|
359 |
+
],
|
360 |
+
allow_flagging="never"
|
361 |
+
)
|
362 |
+
|
363 |
+
# --- Dual Simplex Interface ---
|
364 |
+
from .DualSimplexSolver import DualSimplexSolver
|
365 |
+
|
366 |
+
def solve_dual_simplex_interface(objective_type_str, c_str, A_str, relations_str, b_str):
|
367 |
+
"""
|
368 |
+
Wrapper function to connect DualSimplexSolver with Gradio interface.
|
369 |
+
"""
|
370 |
+
current_log = ["Initializing Dual Simplex Solver Interface..."]
|
371 |
+
|
372 |
+
c = parse_vector(c_str)
|
373 |
+
if not c:
|
374 |
+
current_log.append("Error: Objective coefficients (c) could not be parsed or are empty.")
|
375 |
+
return "Error parsing c", "Error parsing c", "\n".join(current_log)
|
376 |
+
|
377 |
+
A = parse_matrix(A_str)
|
378 |
+
if A.size == 0:
|
379 |
+
current_log.append("Error: Constraint matrix (A) could not be parsed or is empty.")
|
380 |
+
return "Error parsing A", "Error parsing A", "\n".join(current_log)
|
381 |
+
|
382 |
+
b = parse_vector(b_str)
|
383 |
+
if not b:
|
384 |
+
current_log.append("Error: Constraint bounds (b) could not be parsed or are empty.")
|
385 |
+
return "Error parsing b", "Error parsing b", "\n".join(current_log)
|
386 |
+
|
387 |
+
relations = parse_relations(relations_str)
|
388 |
+
if not relations:
|
389 |
+
current_log.append("Error: Constraint relations could not be parsed, are empty, or contain invalid symbols.")
|
390 |
+
return "Error parsing relations", "Error parsing relations", "\n".join(current_log)
|
391 |
+
|
392 |
+
# Basic dimensional validation
|
393 |
+
if A.shape[0] != len(b):
|
394 |
+
current_log.append(f"Dimension mismatch: Number of rows in A ({A.shape[0]}) must equal length of b ({len(b)}).")
|
395 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log)
|
396 |
+
if A.shape[1] != len(c):
|
397 |
+
current_log.append(f"Dimension mismatch: Number of columns in A ({A.shape[1]}) must equal length of c ({len(c)}).")
|
398 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log)
|
399 |
+
if A.shape[0] != len(relations):
|
400 |
+
current_log.append(f"Dimension mismatch: Number of rows in A ({A.shape[0]}) must equal length of relations ({len(relations)}).")
|
401 |
+
return "Dimension Error", "Dimension Error", "\n".join(current_log)
|
402 |
+
|
403 |
+
current_log.append("Inputs parsed and validated successfully.")
|
404 |
+
|
405 |
+
try:
|
406 |
+
solver = DualSimplexSolver(objective_type_str, c, A, relations, b)
|
407 |
+
current_log.append("DualSimplexSolver instantiated.")
|
408 |
+
|
409 |
+
# The solve method now returns: final_solution_str, final_objective_str, log_messages, is_fallback_used_str
|
410 |
+
solution_str, objective_str, solver_log_messages, fallback_info = solver.solve()
|
411 |
+
|
412 |
+
current_log.extend(solver_log_messages)
|
413 |
+
current_log.append(f"Fallback Status: {fallback_info}")
|
414 |
+
|
415 |
+
return solution_str, objective_str, "\n".join(current_log)
|
416 |
+
|
417 |
+
except Exception as e:
|
418 |
+
gr.Error(f"An error occurred during solving with Dual Simplex: {e}")
|
419 |
+
current_log.append(f"Runtime error in Dual Simplex: {e}")
|
420 |
+
return "Solver error", "Solver error", "\n".join(current_log)
|
421 |
+
|
422 |
+
dual_simplex_interface = gr.Interface(
|
423 |
+
fn=solve_dual_simplex_interface,
|
424 |
+
inputs=[
|
425 |
+
gr.Radio(label="Objective Type", choices=["max", "min"], value="max"),
|
426 |
+
gr.Textbox(label="Objective Coefficients (c)", info="Comma-separated, e.g., 4,1"),
|
427 |
+
gr.Textbox(label="Constraint Matrix (A)", info="Rows separated by ';', elements by ',', e.g., 3,1; 4,3; 1,2"),
|
428 |
+
gr.Textbox(label="Constraint Relations", info="Comma-separated, e.g., >=,>=,>="), # Dual simplex typically starts from Ax >= b for max problems if to be converted to <=
|
429 |
+
gr.Textbox(label="Constraint RHS (b)", info="Comma-separated, e.g., 3,6,4")
|
430 |
+
],
|
431 |
+
outputs=[
|
432 |
+
gr.Textbox(label="Optimal Solution (Variables)"),
|
433 |
+
gr.Textbox(label="Optimal Objective Value"),
|
434 |
+
gr.Textbox(label="Solver Log, Tableau Steps, and Fallback Info", lines=15, interactive=False)
|
435 |
+
],
|
436 |
+
title="Dual Simplex Solver for Linear Programs (LP)",
|
437 |
+
description="Solves LPs using the Dual Simplex method. This method is often efficient when an initial basic solution is dual feasible but primal infeasible (e.g. after adding cuts). Input Ax R b where R can be '>=', '<=', or '='.",
|
438 |
+
examples=[
|
439 |
+
[ # Example 1: Max problem, standard form for dual simplex often has >= constraints initially
|
440 |
+
# Maximize Z = 4x1 + x2
|
441 |
+
# Subject to:
|
442 |
+
# 3x1 + x2 >= 3 --> -3x1 - x2 <= -3
|
443 |
+
# 4x1 + 3x2 >= 6 --> -4x1 - 3x2 <= -6
|
444 |
+
# x1 + 2x2 >= 4 --> -x1 - 2x2 <= -4 (Mistake in common example, should be <= to be interesting for dual or needs specific setup)
|
445 |
+
# Let's use a more typical dual simplex starting point:
|
446 |
+
# Min C = 2x1 + x2 (so Max -2x1 -x2)
|
447 |
+
# s.t. x1 + x2 >= 5
|
448 |
+
# 2x1 + x2 >= 6
|
449 |
+
# x1, x2 >=0
|
450 |
+
# Becomes: Max Z' = -2x1 -x2
|
451 |
+
# -x1 -x2 <= -5
|
452 |
+
# -2x1 -x2 <= -6
|
453 |
+
"max", "-2,-1", "-1,-1;-2,-1", "<=,<=", "-5,-6" # This is already in <= form, good for dual if RHS is neg.
|
454 |
+
],
|
455 |
+
[ # Example 2: (Taken from a standard textbook example for Dual Simplex)
|
456 |
+
# Minimize Z = 3x1 + 2x2 + x3
|
457 |
+
# Subject to:
|
458 |
+
# 3x1 + x2 + x3 >= 3
|
459 |
+
# -3x1 + 3x2 + x3 >= 6
|
460 |
+
# x1 + x2 + x3 <= 3 (This constraint makes it interesting)
|
461 |
+
# x1,x2,x3 >=0
|
462 |
+
# For Gradio: obj_type='min', c="3,2,1", A="3,1,1;-3,3,1;1,1,1", relations=">=,>=,<=", b="3,6,3"
|
463 |
+
"min", "3,2,1", "3,1,1;-3,3,1;1,1,1", ">=,>=,<=", "3,6,3"
|
464 |
+
],
|
465 |
+
[ # Example from problem description (slightly modified for typical dual simplex)
|
466 |
+
# Maximize Z = 3x1 + 2x2
|
467 |
+
# Subject to:
|
468 |
+
# 2x1 + x2 <= 18 (Original)
|
469 |
+
# x1 + x2 <= 12 (Original)
|
470 |
+
# x1 <= 5 (Original)
|
471 |
+
# To make it a dual simplex start, we might have transformed it from something else,
|
472 |
+
# or expect some RHS to be negative after initial setup.
|
473 |
+
# For a direct input that might be dual feasible but primal infeasible:
|
474 |
+
# Max Z = x1 + x2
|
475 |
+
# s.t. -2x1 - x2 <= -10 (i.e. 2x1 + x2 >= 10)
|
476 |
+
# -x1 - 2x2 <= -10 (i.e. x1 + 2x2 >= 10)
|
477 |
+
"max", "1,1", "-2,-1;-1,-2", "<=,<=", "-10,-10"
|
478 |
+
]
|
479 |
+
],
|
480 |
+
allow_flagging="never"
|
481 |
+
)
|
maths/university/operations_research/simplex_solver_with_steps.py
CHANGED
@@ -16,13 +16,15 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
16 |
Returns:
|
17 |
- x: Optimal solution
|
18 |
- optimal_value: Optimal objective value
|
|
|
19 |
"""
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
23 |
for i in range(len(b)):
|
24 |
constraint_str = ' + '.join([f"{A[i,j]}x_{j}" for j in range(A.shape[1])])
|
25 |
-
|
26 |
|
27 |
# Convert problem to standard form (for tableau method)
|
28 |
# First handle bounds by adding necessary constraints
|
@@ -67,7 +69,7 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
67 |
base_vars = list(range(n_vars, n_vars + n_constraints)) # Slack variables are initially basic
|
68 |
|
69 |
# Function to print current tableau
|
70 |
-
def print_tableau(tableau, base_vars):
|
71 |
headers = [f"x_{j}" for j in range(n_vars)] + [f"s_{j}" for j in range(n_constraints)] + ["RHS"]
|
72 |
rows = []
|
73 |
row_labels = ["z"] + [f"eq_{i}" for i in range(n_constraints)]
|
@@ -75,13 +77,13 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
75 |
for i, row in enumerate(tableau):
|
76 |
rows.append([row_labels[i]] + [f"{val:.3f}" for val in row])
|
77 |
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
|
82 |
# Print initial tableau
|
83 |
-
|
84 |
-
print_tableau(tableau, base_vars)
|
85 |
|
86 |
# Main simplex loop
|
87 |
iteration = 0
|
@@ -89,15 +91,15 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
89 |
|
90 |
while iteration < max_iterations:
|
91 |
iteration += 1
|
92 |
-
|
93 |
|
94 |
# Find the entering variable (most negative coefficient in objective row for maximization)
|
95 |
entering_col = np.argmin(tableau[0, :-1])
|
96 |
if tableau[0, entering_col] >= -1e-10: # Small negative numbers due to floating-point errors
|
97 |
-
|
98 |
break
|
99 |
|
100 |
-
|
101 |
|
102 |
# Find the leaving variable using min ratio test
|
103 |
ratios = []
|
@@ -108,15 +110,15 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
108 |
ratios.append(tableau[i, -1] / tableau[i, entering_col])
|
109 |
|
110 |
if all(r == np.inf for r in ratios):
|
111 |
-
|
112 |
-
return None, float('inf') # Problem is unbounded
|
113 |
|
114 |
# Find the row with minimum ratio
|
115 |
leaving_row = np.argmin(ratios) + 1 # +1 because we skip the objective row
|
116 |
leaving_var = base_vars[leaving_row - 1]
|
117 |
|
118 |
-
|
119 |
-
|
120 |
|
121 |
# Perform pivot operation
|
122 |
# First, normalize the pivot row
|
@@ -133,12 +135,12 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
133 |
base_vars[leaving_row - 1] = entering_col
|
134 |
|
135 |
# Print updated tableau
|
136 |
-
|
137 |
-
print_tableau(tableau, base_vars)
|
138 |
|
139 |
if iteration == max_iterations:
|
140 |
-
|
141 |
-
return None, None
|
142 |
|
143 |
# Extract solution
|
144 |
x = np.zeros(n_vars)
|
@@ -154,9 +156,9 @@ def simplex_solver_with_steps(c, A, b, bounds):
|
|
154 |
# Calculate objective value
|
155 |
optimal_value = np.dot(c, x)
|
156 |
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
|
161 |
-
return x, optimal_value
|
162 |
|
|
|
16 |
Returns:
|
17 |
- x: Optimal solution
|
18 |
- optimal_value: Optimal objective value
|
19 |
+
- steps_log: List of strings detailing each step
|
20 |
"""
|
21 |
+
steps_log = []
|
22 |
+
steps_log.append("\n--- Starting Simplex Method ---")
|
23 |
+
steps_log.append(f"Objective: Maximize {' + '.join([f'{c[i]}x_{i}' for i in range(len(c))])}")
|
24 |
+
steps_log.append(f"Constraints:")
|
25 |
for i in range(len(b)):
|
26 |
constraint_str = ' + '.join([f"{A[i,j]}x_{j}" for j in range(A.shape[1])])
|
27 |
+
steps_log.append(f" {constraint_str} <= {b[i]}")
|
28 |
|
29 |
# Convert problem to standard form (for tableau method)
|
30 |
# First handle bounds by adding necessary constraints
|
|
|
69 |
base_vars = list(range(n_vars, n_vars + n_constraints)) # Slack variables are initially basic
|
70 |
|
71 |
# Function to print current tableau
|
72 |
+
def print_tableau(tableau, base_vars, steps_log_func):
|
73 |
headers = [f"x_{j}" for j in range(n_vars)] + [f"s_{j}" for j in range(n_constraints)] + ["RHS"]
|
74 |
rows = []
|
75 |
row_labels = ["z"] + [f"eq_{i}" for i in range(n_constraints)]
|
|
|
77 |
for i, row in enumerate(tableau):
|
78 |
rows.append([row_labels[i]] + [f"{val:.3f}" for val in row])
|
79 |
|
80 |
+
steps_log_func.append("\nCurrent Tableau:")
|
81 |
+
steps_log_func.append(tabulate(rows, headers=headers, tablefmt="grid"))
|
82 |
+
steps_log_func.append(f"Basic variables: {[f'x_{v}' if v < n_vars else f's_{v-n_vars}' for v in base_vars]}")
|
83 |
|
84 |
# Print initial tableau
|
85 |
+
steps_log.append("\nInitial tableau:")
|
86 |
+
print_tableau(tableau, base_vars, steps_log)
|
87 |
|
88 |
# Main simplex loop
|
89 |
iteration = 0
|
|
|
91 |
|
92 |
while iteration < max_iterations:
|
93 |
iteration += 1
|
94 |
+
steps_log.append(f"\n--- Iteration {iteration} ---")
|
95 |
|
96 |
# Find the entering variable (most negative coefficient in objective row for maximization)
|
97 |
entering_col = np.argmin(tableau[0, :-1])
|
98 |
if tableau[0, entering_col] >= -1e-10: # Small negative numbers due to floating-point errors
|
99 |
+
steps_log.append("Optimal solution reached - no negative coefficients in objective row")
|
100 |
break
|
101 |
|
102 |
+
steps_log.append(f"Entering variable: {'x_' + str(entering_col) if entering_col < n_vars else 's_' + str(entering_col - n_vars)}")
|
103 |
|
104 |
# Find the leaving variable using min ratio test
|
105 |
ratios = []
|
|
|
110 |
ratios.append(tableau[i, -1] / tableau[i, entering_col])
|
111 |
|
112 |
if all(r == np.inf for r in ratios):
|
113 |
+
steps_log.append("Unbounded solution - no leaving variable found")
|
114 |
+
return steps_log, None, float('inf') # Problem is unbounded
|
115 |
|
116 |
# Find the row with minimum ratio
|
117 |
leaving_row = np.argmin(ratios) + 1 # +1 because we skip the objective row
|
118 |
leaving_var = base_vars[leaving_row - 1]
|
119 |
|
120 |
+
steps_log.append(f"Leaving variable: {'x_' + str(leaving_var) if leaving_var < n_vars else 's_' + str(leaving_var - n_vars)}")
|
121 |
+
steps_log.append(f"Pivot element: {tableau[leaving_row, entering_col]:.3f} at row {leaving_row}, column {entering_col}")
|
122 |
|
123 |
# Perform pivot operation
|
124 |
# First, normalize the pivot row
|
|
|
135 |
base_vars[leaving_row - 1] = entering_col
|
136 |
|
137 |
# Print updated tableau
|
138 |
+
steps_log.append("\nAfter pivot:")
|
139 |
+
print_tableau(tableau, base_vars, steps_log)
|
140 |
|
141 |
if iteration == max_iterations:
|
142 |
+
steps_log.append("Max iterations reached without convergence")
|
143 |
+
return steps_log, None, None
|
144 |
|
145 |
# Extract solution
|
146 |
x = np.zeros(n_vars)
|
|
|
156 |
# Calculate objective value
|
157 |
optimal_value = np.dot(c, x)
|
158 |
|
159 |
+
steps_log.append("\n--- Simplex Method Complete ---")
|
160 |
+
steps_log.append(f"Optimal solution found: {x}")
|
161 |
+
steps_log.append(f"Optimal objective value: {optimal_value}")
|
162 |
|
163 |
+
return steps_log, x, optimal_value
|
164 |
|
maths/university/tests/test_operations_research_interface.py
ADDED
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
import numpy as np
|
3 |
+
import gradio as gr # Import for gr.Error if used in parsing
|
4 |
+
|
5 |
+
# Assuming parsing functions and interface wrappers are in the following module
|
6 |
+
from maths.university.operations_research.operations_research_interface import (
|
7 |
+
parse_vector, parse_matrix, parse_relations, parse_bounds, # Import parsing functions if you want to test them directly too
|
8 |
+
solve_branch_and_bound_interface,
|
9 |
+
solve_dual_simplex_interface,
|
10 |
+
run_simplex_solver_interface
|
11 |
+
)
|
12 |
+
|
13 |
+
# Test for Branch and Bound Interface
|
14 |
+
def test_bnb_interface_valid_input():
|
15 |
+
# Example from its own interface definition
|
16 |
+
c_str = "5,4"
|
17 |
+
A_str = "1,1;2,0;0,1"
|
18 |
+
b_str = "5,6,3"
|
19 |
+
integer_vars_str = "0,1" # Both vars are integer
|
20 |
+
binary_vars_str = "" # No specific binary vars beyond integer_vars
|
21 |
+
maximize_bool = True
|
22 |
+
|
23 |
+
solution_str, objective_str, log_str, fig = solve_branch_and_bound_interface(
|
24 |
+
c_str, A_str, b_str, integer_vars_str, binary_vars_str, maximize_bool
|
25 |
+
)
|
26 |
+
|
27 |
+
# Expected solution for this problem is x0=2, x1=3, objective=22
|
28 |
+
assert "2.000, 3.000" in solution_str or "2, 3" in solution_str # Allow for formatting variations
|
29 |
+
assert "22.000" in objective_str or "22.0" in objective_str
|
30 |
+
assert fig is not None # Check that a figure object is returned
|
31 |
+
assert "Branch and bound completed!" in log_str
|
32 |
+
assert "Optimal objective: 22.0" in log_str # More specific check
|
33 |
+
|
34 |
+
def test_bnb_interface_parsing_error():
|
35 |
+
# Invalid matrix string (row length mismatch)
|
36 |
+
solution_str, objective_str, log_str, fig = solve_branch_and_bound_interface(
|
37 |
+
"1,2", "1,2;3", "10,12", "", "", True # b_str needs two elements if A has two rows
|
38 |
+
)
|
39 |
+
# The error message comes from parse_matrix via gr.Warning, which is then caught by the wrapper.
|
40 |
+
# The wrapper appends to its own log_messages.
|
41 |
+
assert "Error parsing matrix" in log_str or "Could not parse matrix" in log_str
|
42 |
+
assert fig is None
|
43 |
+
|
44 |
+
# Test for Dual Simplex Interface
|
45 |
+
def test_dual_simplex_interface_valid_input():
|
46 |
+
# Example from its own interface definition, but made consistent for Dual Simplex properties
|
47 |
+
# Max Z = -2x1 -x2 (originally Min 2x1+x2)
|
48 |
+
# s.t. -x1 -x2 <= -5 (originally x1+x2 >= 5)
|
49 |
+
# -2x1 -x2 <= -6 (originally 2x1+x2 >= 6)
|
50 |
+
obj_type = "max"
|
51 |
+
c_str = "-2,-1"
|
52 |
+
A_str = "-1,-1;-2,-1"
|
53 |
+
relations_str = "<=,<=" # Correct for typical dual simplex tableau setup
|
54 |
+
b_str = "-5,-6"
|
55 |
+
|
56 |
+
solution_str, objective_str, log_str = solve_dual_simplex_interface(
|
57 |
+
obj_type, c_str, A_str, relations_str, b_str
|
58 |
+
)
|
59 |
+
|
60 |
+
# For Z' = -2x1 -x2 s.t. -x1-x2<=-5, -2x1-x2<=-6. Optimal is x1=1, x2=4, Z'=-6. (Min Z = 6)
|
61 |
+
# The solution string format depends on the solver's _print_results
|
62 |
+
assert "x1: 1.000" in solution_str or "x1=1.000" in solution_str
|
63 |
+
assert "x2: 4.000" in solution_str or "x2=4.000" in solution_str
|
64 |
+
assert "-6.000" in objective_str # Max -Z
|
65 |
+
assert "Optimal Solution Found" in log_str or "Final Solution" in log_str
|
66 |
+
|
67 |
+
def test_dual_simplex_interface_dim_mismatch():
|
68 |
+
solution_str, objective_str, log_str = solve_dual_simplex_interface(
|
69 |
+
"max", "1,2", "1,1;2,2", "<=", "10,12" # relations_str has 1 relation, A has 2 rows
|
70 |
+
)
|
71 |
+
assert "Dimension mismatch" in log_str # Check for error message
|
72 |
+
|
73 |
+
# Test for Simplex Solver Interface
|
74 |
+
def test_simplex_solver_interface_valid_input():
|
75 |
+
# Example from its own interface definition
|
76 |
+
c_str = "3,5"
|
77 |
+
A_str = "1,0;0,2;3,2"
|
78 |
+
b_str = "4,12,18"
|
79 |
+
bounds_str = "0,None;0,None" # Non-negative
|
80 |
+
|
81 |
+
solution_str, objective_str, steps_str = run_simplex_solver_interface(
|
82 |
+
c_str, A_str, b_str, bounds_str
|
83 |
+
)
|
84 |
+
|
85 |
+
# Expected: x1=2, x2=6, Z=36
|
86 |
+
assert "2.000, 6.000" in solution_str or "2, 6" in solution_str
|
87 |
+
assert "36.000" in objective_str or "36.0" in objective_str
|
88 |
+
assert "Simplex Method Complete" in steps_str
|
89 |
+
assert "Initial tableau" in steps_str
|
90 |
+
|
91 |
+
def test_simplex_solver_interface_invalid_bounds():
|
92 |
+
solution_str, objective_str, steps_str = run_simplex_solver_interface(
|
93 |
+
"1,2", "1,1;2,1", "10,12", "0,None;0" # Malformed bounds pair
|
94 |
+
)
|
95 |
+
assert "Error parsing var_bounds" in steps_str or "Could not parse bounds" in steps_str
|
96 |
+
|
97 |
+
|
98 |
+
# Test parsing functions directly
|
99 |
+
def test_parse_matrix_valid():
|
100 |
+
assert np.array_equal(parse_matrix("1,2;3,4"), np.array([[1.0,2.0],[3.0,4.0]]))
|
101 |
+
|
102 |
+
def test_parse_matrix_invalid_char():
|
103 |
+
# parse_matrix issues a gr.Warning and returns an empty np.array([])
|
104 |
+
# The test checks if the returned array is empty.
|
105 |
+
# We cannot directly test gr.Warning without a more complex setup.
|
106 |
+
result = parse_matrix("1,2;3,a")
|
107 |
+
assert isinstance(result, np.ndarray)
|
108 |
+
assert result.size == 0
|
109 |
+
|
110 |
+
def test_parse_matrix_invalid_row_length():
|
111 |
+
result = parse_matrix("1,2;3,4,5")
|
112 |
+
assert isinstance(result, np.ndarray)
|
113 |
+
assert result.size == 0
|
114 |
+
|
115 |
+
def test_parse_vector_valid():
|
116 |
+
assert parse_vector("1,2,3.5") == [1.0, 2.0, 3.5]
|
117 |
+
|
118 |
+
def test_parse_vector_invalid():
|
119 |
+
assert parse_vector("1,a,3") == []
|
120 |
+
|
121 |
+
def test_parse_relations_valid():
|
122 |
+
assert parse_relations("<=,>=,=") == ["<=", ">=", "="]
|
123 |
+
|
124 |
+
def test_parse_relations_invalid():
|
125 |
+
assert parse_relations("<=,>,=") == [] # '>' is invalid
|
126 |
+
|
127 |
+
def test_parse_bounds_valid():
|
128 |
+
assert parse_bounds("0,None;10,20.5;None,5") == [(0.0, None), (10.0, 20.5), (None, 5.0)]
|
129 |
+
|
130 |
+
def test_parse_bounds_invalid_format():
|
131 |
+
assert parse_bounds("0,None;10,20,30") == [] # middle pair has 3 parts
|
132 |
+
|
133 |
+
def test_parse_bounds_invalid_order():
|
134 |
+
assert parse_bounds("10,0") == [] # lower > upper
|