spagestic commited on
Commit
d8c6327
·
1 Parent(s): 9ed7b8a

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 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
- print(tabulate(self.steps_table, headers=headers, tablefmt="grid"))
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
- print("Step 1: Solving root relaxation (continuous problem)")
177
  x_root, obj_root = self.solve_relaxation(lower_bounds, upper_bounds)
178
 
179
  if x_root is None:
180
- print("Root problem infeasible")
181
- return None, float('-inf') if self.maximize else float('inf')
 
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
- print(f"Root relaxation objective: {obj_root:.6f}")
188
- print(f"Root solution: {x_root}")
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
- print("Root solution is integer-feasible! No need for branching.")
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.visualize_graph()
209
- return x_root, obj_root
 
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
- print("\nStarting branch and bound process:")
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
- print(f"\nStep {self.nodes_explored + 1}: Exploring node {node_name}")
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
- print(f" Branching on binary variable x_{branch_var + 1} with value {branch_val:.6f}")
248
- print(f" Creating two branches: x_{branch_var + 1} = 0 and x_{branch_var + 1} = 1")
249
  else:
250
  floor_val = math.floor(branch_val)
251
  ceil_val = math.ceil(branch_val)
252
- print(f" Branching on variable x_{branch_var + 1} with value {branch_val:.6f}")
253
- print(f" Creating two branches: x_{branch_var + 1} ≤ {floor_val} and x_{branch_var + 1} ≥ {ceil_val}")
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
- print(f" {left_node} is infeasible")
282
  else:
283
- print(f" {left_node} relaxation objective: {obj_floor:.6f}")
284
- print(f" {left_node} solution: {x_floor}")
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
- print(f" Found new best integer solution with objective {self.best_objective:.6f}")
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
- print(f" {right_node} is infeasible")
329
  else:
330
- print(f" {right_node} relaxation objective: {obj_ceil:.6f}")
331
- print(f" {right_node} solution: {x_ceil}")
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
- print(f" Found new best integer solution with objective {self.best_objective:.6f}")
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
- print("\nBranch and bound completed!")
370
- print(f"Nodes explored: {self.nodes_explored}")
371
 
372
  if self.best_solution is not None:
373
- print(f"Optimal objective: {self.best_objective:.6f}")
374
- print(f"Optimal solution: {self.best_solution}")
375
  else:
376
- print("No feasible integer solution found")
377
 
378
- # Display steps table
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
- print("\nWarning: Initial tableau is not dual feasible (objective row has negative coefficients).")
138
- print("The standard Dual Simplex method might not apply directly or may require Phase I.")
139
  # For this implementation, we'll proceed, but it might fail if assumption is violated.
140
 
141
 
142
  def _print_tableau(self, iteration):
143
- """Prints the current state of the tableau."""
144
- print(f"\n--- Iteration {iteration} ---")
 
145
  header = ["BV"] + ["Z"] + self.var_names + ["RHS"]
146
- print(" ".join(f"{h:>8}" for h in header))
147
- print("-" * (len(header) * 9))
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
- row_str = [f"{row_bv_name:>8}"]
154
- row_str.extend([f"{val: >8.3f}" for val in self.tableau[i]])
155
- print(" ".join(row_str))
156
- print("-" * (len(header) * 9))
 
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
- print(f"\nStep: Select Pivot Row (Leaving Variable)")
172
- print(f" RHS values (b): {rhs_values}")
173
  leaving_var_idx = self.basic_vars[pivot_row_index - 1]
174
  leaving_var_name = self.var_names[leaving_var_idx]
175
- print(f" Most negative RHS is {self.tableau[pivot_row_index, -1]:.3f} in Row {pivot_row_index} (Basic Var: {leaving_var_name}).")
176
- print(f" Leaving Variable: {leaving_var_name} (Row {pivot_row_index})")
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
- print(f"\nStep: Select Pivot Column (Entering Variable) using Ratio Test")
189
- print(f" Pivot Row (Row {pivot_row_index}) coefficients (excluding Z, RHS): {pivot_row}")
190
- print(f" Objective Row coefficients (excluding Z, RHS): {objective_row}")
191
- print(f" Calculating ratios = ObjCoeff / abs(PivotRowCoeff) for PivotRowCoeff < 0:")
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
- print(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}")
205
 
206
- # Update minimum ratio
207
  if ratio < min_ratio:
208
  min_ratio = ratio
209
- pivot_col_index = col_tableau_index # Store the tableau column index
210
 
211
  if not found_negative_coeff:
212
- print(" No negative coefficients found in the pivot row.")
213
- return -1 # Indicates primal infeasibility (dual unboundedness)
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
- print(f" Tie detected for minimum ratio ({min_ratio:.3f}) among variables: {[self.var_names[idx] for idx in min_ratio_vars]}.")
219
- # Apply Bland's rule: choose the variable with the smallest index
220
- pivot_col_index = min(min_ratio_vars) + 1 # +1 for tableau index
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] # -1 to get var_name index
224
- print(f" Minimum ratio is {min_ratio:.3f} for variable {entering_var_name} (Column {pivot_col_index}).")
225
- print(f" Entering Variable: {entering_var_name} (Column {pivot_col_index})")
226
  else:
227
- # This case should technically not be reached if found_negative_coeff was true
228
- print("Error in ratio calculation or tie-breaking.")
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
- print(f"\nStep: Pivot Operation")
239
- print(f" Pivot Element: {pivot_element:.3f} at (Row {pivot_row_index}, Col {pivot_col_index})")
240
 
241
  if abs(pivot_element) < TOLERANCE:
242
- print("Error: Pivot element is zero. Cannot proceed.")
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
- # 1. Normalize the pivot row
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
- # 2. Eliminate other entries in the pivot column
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: # Only perform if factor is non-zero
256
- print(f" Row {i} = Row {i} - ({factor:.3f}) * (New Row {pivot_row_index})")
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 # Convert tableau col index to var_names index
263
  self.basic_vars[pivot_row_index - 1] = new_basic_var_index
264
- print(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}.")
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: (tableau, basic_vars) if successful using dual simplex,
277
- or a dictionary of results if fallback solvers were used
278
  """
279
- print("--- Starting Dual Simplex Method ---")
 
 
 
280
  if self.tableau is None:
281
- print("Error: Tableau not initialized.")
282
- return None
283
 
284
  iteration = 0
285
- self._print_tableau(iteration)
 
286
 
287
- while iteration < 100: # Safety break for too many iterations
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
- print("\n--- Optimal Solution Found ---")
294
- print(" All RHS values are non-negative.")
295
- self._print_results()
296
- return self.tableau, self.basic_vars
 
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
- print("\n--- Primal Problem Infeasible ---")
304
- print(f" All coefficients in Pivot Row {pivot_row_index} are non-negative, but RHS is negative.")
305
- print(" The dual problem is unbounded, implying the primal problem has no feasible solution.")
306
-
307
  if use_fallbacks:
308
- return self._try_fallback_solvers("primal_infeasible")
309
- return None, None # Indicate infeasibility
310
-
 
311
  elif pivot_col_index == -2:
312
- # Error during pivot column selection
313
- print("\n--- Error during pivot column selection ---")
314
-
315
  if use_fallbacks:
316
- return self._try_fallback_solvers("pivot_error")
317
- return None, None
 
318
 
319
- # 4. Perform Pivot Operation
320
  try:
321
  self._pivot(pivot_row_index, pivot_col_index)
322
  except ZeroDivisionError as e:
323
- print(f"\n--- Error during pivot operation: {e} ---")
324
-
325
  if use_fallbacks:
326
- return self._try_fallback_solvers("numerical_instability")
327
- return None, None
 
328
 
329
- # Print the tableau after pivoting
330
- self._print_tableau(iteration)
331
 
332
- print("\n--- Maximum Iterations Reached ---")
333
- print(" The algorithm did not converge within the iteration limit.")
334
- print(" This might indicate cycling or a very large problem.")
335
-
336
  if use_fallbacks:
337
- return self._try_fallback_solvers("iteration_limit")
338
- return None, None # Indicate non-convergence
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  def _try_fallback_solvers(self, error_type):
341
  """
342
- Tries alternative solvers when the dual simplex method fails.
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
- print(f"\n--- Using Fallback Solvers due to '{error_type}' ---")
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
- # First try using solve_lp_via_dual (which uses complementary slackness)
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.original_c,
364
- self.original_A,
365
- self.original_relations,
366
- self.original_b
367
  )
368
 
369
  results["dual_approach_result"] = {
370
- "status": status,
371
- "message": message,
372
- "primal_solution": primal_sol,
373
- "dual_solution": dual_sol,
374
- "objective_value": obj_val
375
  }
 
 
 
 
376
 
377
- print(f"Dual Approach Result: {message}")
378
- if status == 0 and primal_sol:
379
- print(f"Objective Value: {obj_val}")
380
- return results
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": status,
394
- "message": message,
395
- "primal_solution": primal_sol,
396
- "objective_value": obj_val
397
  }
398
-
399
- print(f"Direct Solver Result: {message}")
400
- if status == 0 and primal_sol:
401
- print(f"Objective Value: {obj_val}")
402
-
403
  return results
404
 
405
  def _print_results(self):
406
- """Prints the final solution."""
407
- print("\n--- Final Solution ---")
408
- self._print_tableau("Final")
 
 
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 # Correct for Min Z = -Max(-Z)
414
- print(f"Optimal Objective Value (Min Z): {final_obj_value:.6f}")
415
- else:
416
- print(f"Optimal Objective Value (Max Z): {final_obj_value:.6f}")
417
 
418
- # Variable Values
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
- print(f" {var_name}: {value:.6f}")
433
- solution[var_name] = value
434
 
435
- # Optionally print slack variable values
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
- print(f" {var_name}: {value:.6f}")
 
 
 
 
 
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
- st.markdown("\n--- Starting Simplex Method ---")
21
- st.text(f"Objective: Maximize {' + '.join([f'{c[i]}x_{i}' for i in range(len(c))])}")
22
- st.text(f"Constraints:")
 
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
- st.text(f" {constraint_str} <= {b[i]}")
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
- st.text("\nCurrent Tableau:")
79
- st.text(tabulate(rows, headers=headers, tablefmt="grid"))
80
- st.text(f"Basic variables: {[f'x_{v}' if v < n_vars else f's_{v-n_vars}' for v in base_vars]}")
81
 
82
  # Print initial tableau
83
- st.text("\nInitial tableau:")
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
- st.text(f"\n--- Iteration {iteration} ---")
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
- st.text("Optimal solution reached - no negative coefficients in objective row")
98
  break
99
 
100
- st.text(f"Entering variable: {'x_' + str(entering_col) if entering_col < n_vars else 's_' + str(entering_col - n_vars)}")
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
- st.text("Unbounded solution - no leaving variable found")
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
- st.text(f"Leaving variable: {'x_' + str(leaving_var) if leaving_var < n_vars else 's_' + str(leaving_var - n_vars)}")
119
- st.text(f"Pivot element: {tableau[leaving_row, entering_col]:.3f} at row {leaving_row}, column {entering_col}")
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
- st.text("\nAfter pivot:")
137
- print_tableau(tableau, base_vars)
138
 
139
  if iteration == max_iterations:
140
- st.text("Max iterations reached without convergence")
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
- st.markdown("\n--- Simplex Method Complete ---")
158
- st.text(f"Optimal solution found: {x}")
159
- st.text(f"Optimal objective value: {optimal_value}")
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