import math import matplotlib.pyplot as plt import pandas as pd from gurobipy import GRB, Model, quicksum import utils.logger as logger logger = logger.get_logger(__name__) def solve_diet_problem(foods_df, requirements_df): """ Solves the Diet Problem using Linear Programming with flexible number of foods and nutrients. Args: foods_df (pd.DataFrame): DataFrame with columns ['Food', 'Cost'] + nutrient columns requirements_df (pd.DataFrame): DataFrame with columns ['Nutrient', 'Minimum'] Returns: tuple: (result_df, fig) where result_df contains the optimal solution and fig is a matplotlib figure Raises: TypeError: If inputs are not DataFrames or contain invalid data types ValueError: If data validation fails or optimization problem cannot be solved Exception: If Gurobi solver encounters an error """ logger.info("Starting Gurobi model for Diet Problem") try: # Type validation if not isinstance(foods_df, pd.DataFrame): raise TypeError("foods_df must be a pandas DataFrame") if not isinstance(requirements_df, pd.DataFrame): raise TypeError("requirements_df must be a pandas DataFrame") # Empty data validation if foods_df.empty: raise ValueError( "Foods data cannot be empty. Please provide at least one food item." ) if requirements_df.empty: raise ValueError( "Requirements data cannot be empty. Please provide at least one nutritional requirement." ) # Required columns validation required_food_cols = {"Food", "Cost"} if not required_food_cols.issubset(foods_df.columns): missing = required_food_cols - set(foods_df.columns) raise ValueError( f"Missing required columns in foods data: {missing}. Required columns: {required_food_cols}" ) required_req_cols = {"Nutrient", "Minimum"} if not required_req_cols.issubset(requirements_df.columns): missing = required_req_cols - set(requirements_df.columns) raise ValueError( f"Missing required columns in requirements data: {missing}. Required columns: {required_req_cols}" ) # Duplicate validation if foods_df["Food"].duplicated().any(): duplicates = foods_df[foods_df["Food"].duplicated()]["Food"].tolist() raise ValueError( f"Duplicate food names found: {duplicates}. Each food must have a unique name." ) if requirements_df["Nutrient"].duplicated().any(): duplicates = requirements_df[requirements_df["Nutrient"].duplicated()][ "Nutrient" ].tolist() raise ValueError( f"Duplicate nutrient names found: {duplicates}. Each nutrient must be unique." ) # Get nutrient columns (all columns except 'Food' and 'Cost') nutrient_cols = [col for col in foods_df.columns if col not in ["Food", "Cost"]] if not nutrient_cols: raise ValueError( "No nutrient columns found in foods data. Please include at least one nutrient column (e.g., 'Protein', 'Fat', 'Carbs')." ) # Data type validation try: foods_df["Cost"] = pd.to_numeric(foods_df["Cost"], errors="raise") except (ValueError, TypeError) as e: raise ValueError( f"Cost column contains non-numeric values. All costs must be numbers. Error: {str(e)}" ) try: requirements_df["Minimum"] = pd.to_numeric( requirements_df["Minimum"], errors="raise" ) except (ValueError, TypeError) as e: raise ValueError( f"Minimum column contains non-numeric values. All requirements must be numbers. Error: {str(e)}" ) for col in nutrient_cols: try: foods_df[col] = pd.to_numeric(foods_df[col], errors="raise") except (ValueError, TypeError) as e: raise ValueError( f"Nutrient column '{col}' contains non-numeric values. All nutrient values must be numbers. Error: {str(e)}" ) # Value range validation if (foods_df["Cost"] < 0).any(): negative_costs = foods_df[foods_df["Cost"] < 0]["Food"].tolist() raise ValueError( f"Negative costs found for foods: {negative_costs}. All costs must be non-negative." ) if (requirements_df["Minimum"] <= 0).any(): non_positive_reqs = requirements_df[requirements_df["Minimum"] <= 0][ "Nutrient" ].tolist() raise ValueError( f"Non-positive requirements found for nutrients: {non_positive_reqs}. All requirements must be positive." ) for col in nutrient_cols: if (foods_df[col] < 0).any(): negative_nutrients = foods_df[foods_df[col] < 0]["Food"].tolist() raise ValueError( f"Negative {col} values found for foods: {negative_nutrients}. All nutrient values must be non-negative." ) # Check if any required nutrient is missing from foods data missing_nutrients = set(requirements_df["Nutrient"]) - set(nutrient_cols) if missing_nutrients: raise ValueError( f"Required nutrients missing from foods data: {missing_nutrients}. Please add these nutrient columns to your foods data." ) # Check for zero costs (potential unbounded solution) if (foods_df["Cost"] == 0).any(): zero_cost_foods = foods_df[foods_df["Cost"] == 0]["Food"].tolist() logger.warning( f"Foods with zero cost detected: {zero_cost_foods}. This may lead to unrealistic solutions." ) # Feasibility pre-check: ensure at least one food provides each required nutrient for _, req_row in requirements_df.iterrows(): nutrient = req_row["Nutrient"] if nutrient in nutrient_cols: max_nutrient = foods_df[nutrient].max() if max_nutrient == 0: raise ValueError( f"No food provides the required nutrient '{nutrient}'. Problem is infeasible." ) if max_nutrient < req_row["Minimum"]: logger.warning( f"Maximum {nutrient} content ({max_nutrient}) is less than requirement ({req_row['Minimum']}). May need multiple foods." ) except Exception as e: logger.error(f"Data validation failed: {str(e)}") raise # Create optimization model try: model = Model("DietProblem") model.setParam("OutputFlag", 0) # Set time limit to prevent infinite solving (60 seconds) model.setParam("TimeLimit", 60) logger.info("Creating decision variables...") # Decision variables: units of each food food_vars = {} for i, food_name in enumerate(foods_df["Food"]): if pd.isna(food_name) or str(food_name).strip() == "": raise ValueError( f"Empty or invalid food name at row {i}. All foods must have valid names." ) food_vars[food_name] = model.addVar(name=f"Food_{i}_{food_name}", lb=0) logger.info("Setting objective function...") # Objective: Minimize total cost model.setObjective( quicksum( foods_df.loc[foods_df["Food"] == food, "Cost"].iloc[0] * var for food, var in food_vars.items() ), GRB.MINIMIZE, ) logger.info("Adding constraints...") # Constraints: Nutritional requirements for _, req_row in requirements_df.iterrows(): nutrient = req_row["Nutrient"] min_requirement = req_row["Minimum"] if pd.isna(nutrient) or str(nutrient).strip() == "": raise ValueError( "Empty or invalid nutrient name found. All nutrients must have valid names." ) if nutrient in nutrient_cols: model.addConstr( quicksum( foods_df.loc[foods_df["Food"] == food, nutrient].iloc[0] * var for food, var in food_vars.items() ) >= min_requirement, name=f"{nutrient}_requirement", ) else: logger.warning( f"Nutrient '{nutrient}' not found in foods data. Skipping constraint." ) logger.info("Solving optimization model...") # Solve the model model.optimize() # Enhanced status checking if model.status == GRB.OPTIMAL: logger.info("Optimal solution found successfully") elif model.status == GRB.INFEASIBLE: logger.error("Problem is infeasible") # Try to compute IIS (Irreducible Inconsistent Subsystem) for debugging try: model.computeIIS() iis_constraints = [] for constr in model.getConstrs(): if constr.IISConstr: iis_constraints.append(constr.ConstrName) if iis_constraints: raise ValueError( f"Problem is infeasible. Conflicting constraints: {iis_constraints}. Try reducing requirements or adding more diverse foods." ) else: raise ValueError( "Problem is infeasible. The nutritional requirements cannot be met with the provided foods. Try reducing requirements or adding more foods with different nutritional profiles." ) except Exception as iis_error: logger.warning(f"Could not compute IIS: {str(iis_error)}") raise ValueError( "Problem is infeasible. The nutritional requirements cannot be met with the provided foods. Try reducing requirements or adding more diverse foods." ) elif model.status == GRB.UNBOUNDED: logger.error("Problem is unbounded") raise ValueError( "Problem is unbounded. This usually occurs when some foods have zero cost. Please ensure all foods have positive costs." ) elif model.status == GRB.TIME_LIMIT: logger.error("Time limit reached") raise ValueError( "Solver time limit reached (60 seconds). The problem may be too complex. Try simplifying the problem or contact support." ) elif model.status == GRB.INTERRUPTED: logger.error("Solver was interrupted") raise ValueError("Solver was interrupted. Please try again.") elif model.status == GRB.NUMERIC: logger.error("Numerical difficulties encountered") raise ValueError( "Numerical difficulties encountered. Try using simpler numbers or scaling your data." ) else: logger.error(f"Unexpected solver status: {model.status}") raise ValueError( f"Failed to find an optimal solution. Solver status: {model.status}. Please check your input data." ) except Exception as gurobi_error: logger.error(f"Gurobi optimization error: {str(gurobi_error)}") if "Gurobi" in str(type(gurobi_error)): raise ValueError( f"Gurobi solver error: {str(gurobi_error)}. Please check your Gurobi installation and license." ) else: raise # Extract results try: total_cost = model.objVal logger.info(f"Optimal solution found with total cost: {total_cost:.2f}") # Create results dataframe result_rows = [] for food_name, var in food_vars.items(): try: food_row = foods_df[foods_df["Food"] == food_name].iloc[0] amount = var.X result_row = { "Food": food_name, "Units": amount, "Cost": food_row["Cost"] * amount, } # Add nutrient contributions for nutrient in nutrient_cols: result_row[nutrient] = food_row[nutrient] * amount result_rows.append(result_row) except Exception as extract_error: logger.error( f"Error extracting results for food '{food_name}': {str(extract_error)}" ) raise ValueError( f"Error processing results for food '{food_name}': {str(extract_error)}" ) result_df = pd.DataFrame(result_rows) # Validate results if result_df.empty: raise ValueError( "No results generated. This is unexpected after finding an optimal solution." ) # Check if any solution violates non-negativity (shouldn't happen, but good to check) if (result_df["Units"] < -1e-6).any(): negative_foods = result_df[result_df["Units"] < -1e-6]["Food"].tolist() logger.warning( f"Negative amounts found for foods (numerical error): {negative_foods}" ) # Clamp negative values to zero result_df["Units"] = result_df["Units"].clip(lower=0) except Exception as result_error: logger.error(f"Error extracting optimization results: {str(result_error)}") raise ValueError( f"Failed to extract optimization results: {str(result_error)}" ) # Create flexible visualizations try: fig, axs = plt.subplots(2, 2, figsize=(14, 10)) fig.suptitle("Diet Problem Optimization Results", fontsize=16, y=1.02) # Plot 1: Food Units - only show foods with positive amounts foods_with_amounts = result_df[result_df["Units"] > 0.001] if not foods_with_amounts.empty: colors = plt.cm.Set3(range(len(foods_with_amounts))) axs[0, 0].bar( foods_with_amounts["Food"], foods_with_amounts["Units"], color=colors ) axs[0, 0].set_title("Optimal Food Quantities") axs[0, 0].set_ylabel("Units") axs[0, 0].tick_params(axis="x", rotation=45) else: axs[0, 0].text( 0.5, 0.5, "No foods selected\n(all amounts are zero)", ha="center", va="center", transform=axs[0, 0].transAxes, ) axs[0, 0].set_title("Optimal Food Quantities") # Plot 2: Cost Breakdown if not foods_with_amounts.empty: axs[0, 1].bar( foods_with_amounts["Food"], foods_with_amounts["Cost"], color=colors ) axs[0, 1].set_title("Cost per Food Type") axs[0, 1].set_ylabel("Cost ($)") axs[0, 1].tick_params(axis="x", rotation=45) else: axs[0, 1].text( 0.5, 0.5, "No costs to display", ha="center", va="center", transform=axs[0, 1].transAxes, ) axs[0, 1].set_title("Cost per Food Type") # Plot 3: Nutritional Requirements vs Achieved achieved_nutrients = {} for nutrient in nutrient_cols: achieved_nutrients[nutrient] = result_df[nutrient].sum() requirements_dict = dict( zip(requirements_df["Nutrient"], requirements_df["Minimum"]) ) nutrients = list(nutrient_cols) requirements = [requirements_dict.get(nut, 0) for nut in nutrients] achieved = [achieved_nutrients.get(nut, 0) for nut in nutrients] if nutrients: x_pos = range(len(nutrients)) width = 0.35 axs[1, 0].bar( [i - width / 2 for i in x_pos], requirements, width, label="Required", color="red", alpha=0.7, ) axs[1, 0].bar( [i + width / 2 for i in x_pos], achieved, width, label="Achieved", color="green", alpha=0.7, ) axs[1, 0].set_title("Nutritional Requirements vs Achieved") axs[1, 0].set_ylabel("Units") axs[1, 0].set_xticks(x_pos) axs[1, 0].set_xticklabels(nutrients, rotation=45) axs[1, 0].legend() else: axs[1, 0].text( 0.5, 0.5, "No nutrients to display", ha="center", va="center", transform=axs[1, 0].transAxes, ) axs[1, 0].set_title("Nutritional Requirements vs Achieved") # Plot 4: Summary Information summary_data = [("Total Cost", total_cost)] for nutrient in nutrient_cols: summary_data.append( (f"Total {nutrient}", achieved_nutrients.get(nutrient, 0)) ) if summary_data: summary_labels, summary_values = zip(*summary_data) colors_summary = plt.cm.viridis(range(len(summary_data))) bars = axs[1, 1].bar(summary_labels, summary_values, color=colors_summary) axs[1, 1].set_title("Diet Summary") axs[1, 1].set_ylabel("Value") # Add value labels on bars for bar, value in zip(bars, summary_values): height = bar.get_height() axs[1, 1].text( bar.get_x() + bar.get_width() / 2.0, height + max(summary_values) * 0.01, f"{value:.2f}", ha="center", va="bottom", fontsize=8, ) axs[1, 1].tick_params(axis="x", rotation=45) else: axs[1, 1].text( 0.5, 0.5, "No summary data to display", ha="center", va="center", transform=axs[1, 1].transAxes, ) axs[1, 1].set_title("Diet Summary") fig.tight_layout() logger.info("Visualization created successfully") except Exception as plot_error: logger.error(f"Error creating visualization: {str(plot_error)}") # Create a simple fallback plot fig, ax = plt.subplots(1, 1, figsize=(8, 6)) ax.text( 0.5, 0.5, f"Visualization Error\nOptimal Cost: ${total_cost:.2f}\nSee results table for details", ha="center", va="center", transform=ax.transAxes, fontsize=12, ) ax.set_title("Diet Problem Results") ax.axis("off") return result_df, fig def _validate_plne_input(data, vehicle_capacity, num_vehicles): required_cols = {"Node", "X", "Y", "Demand"} if not isinstance(data, pd.DataFrame): raise TypeError("Input 'data' must be a pandas DataFrame.") if not required_cols.issubset(data.columns): missing = required_cols - set(data.columns) raise ValueError(f"Missing required columns in 'data': {missing}") if not isinstance(vehicle_capacity, (int, float)) or vehicle_capacity <= 0: raise ValueError("'vehicle_capacity' must be a positive number.") if not isinstance(num_vehicles, int) or num_vehicles <= 0: raise ValueError("'num_vehicles' must be a positive integer.") def _prepare_plne_data(data): coords = {int(r.Node): (r.X, r.Y) for _, r in data.iterrows()} demand = {int(r.Node): r.Demand for _, r in data.iterrows()} nodes = list(coords.keys()) depot = 0 if depot not in nodes: raise ValueError("Depot node (0) is missing from input.") customers = [i for i in nodes if i != depot] return coords, demand, nodes, depot, customers def _compute_distance_matrix(coords, nodes): return { (i, j): math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]) for i in nodes for j in nodes if i != j } def _reconstruct_routes(sol, depot, K): starts = [j for (i, j), val in sol.items() if i == depot and val > 0.5] if len(starts) != K: raise ValueError(f"Expected {K} routes out of depot, got {len(starts)}") succ = {i: j for (i, j), val in sol.items() if i != depot and val > 0.5} routes = [] for start in starts: route = [depot, start] cur = start while cur != depot: nxt = succ.get(cur) if nxt is None: raise ValueError(f"Incomplete route starting at node {start}.") route.append(nxt) cur = nxt routes.append(route) return routes def _build_routes_df(routes, demand, coords, depot): rows = [] for ridx, route in enumerate(routes, start=1): load = sum(demand[n] for n in route if n != depot) dist = sum( math.hypot( coords[route[i]][0] - coords[route[i + 1]][0], coords[route[i]][1] - coords[route[i + 1]][1], ) for i in range(len(route) - 1) ) rows.append( { "Route": ridx, "Sequence": "→".join(str(n) for n in route), "Load": load, "Distance": dist, } ) return pd.DataFrame(rows) def _plot_plne(routes, routes_df, coords, customers, depot, K): fig, axs = plt.subplots(1, 2, figsize=(14, 6)) ax = axs[0] ax.scatter(*zip(*[coords[i] for i in customers]), c="blue", label="Customers") ax.scatter(*coords[depot], c="red", s=100, label="Depot") colors = plt.cm.get_cmap("tab10", K) for ridx, route in enumerate(routes): pts = [coords[n] for n in route] xs, ys = zip(*pts) ax.plot(xs, ys, "-o", color=colors(ridx), label=f"Route {ridx+1}") ax.set_title("Vehicle Routes") ax.legend(loc="upper right") ax2 = axs[1] bar_width = 0.35 idx = range(len(routes_df)) ax2.bar(idx, routes_df["Load"], bar_width, label="Load") ax2.bar( [i + bar_width for i in idx], routes_df["Distance"], bar_width, label="Distance" ) ax2.set_xticks([i + bar_width / 2 for i in idx]) ax2.set_xticklabels([f"R{r}" for r in routes_df["Route"]]) ax2.set_ylabel("Units / Distance") ax2.set_title("Load vs Distance per Route") ax2.legend() fig.tight_layout() return fig def solve_plne(data: pd.DataFrame, vehicle_capacity: float, num_vehicles: int): """ data: DataFrame with columns ["Node","X","Y","Demand"] vehicle_capacity: capacity Q of each vehicle num_vehicles: number of vehicles K Returns: (routes_df, fig) - routes_df: DataFrame with columns ["Route","Sequence","Load","Distance"] - fig: matplotlib.figure.Figure with the route‐map and summary bars """ try: _validate_plne_input(data, vehicle_capacity, num_vehicles) coords, demand, nodes, depot, customers = _prepare_plne_data(data) Q, K = vehicle_capacity, num_vehicles cost = _compute_distance_matrix(coords, nodes) m = Model("CVRP") m.setParam("OutputFlag", 0) x = m.addVars(cost.keys(), vtype=GRB.BINARY, name="x") u = m.addVars(nodes, lb=0, ub=Q, vtype=GRB.CONTINUOUS, name="u") m.setObjective(quicksum(cost[i, j] * x[i, j] for i, j in cost), GRB.MINIMIZE) m.addConstrs( (quicksum(x[i, j] for j in nodes if j != i) == 1 for i in customers), "leave", ) m.addConstrs( (quicksum(x[i, j] for i in nodes if i != j) == 1 for j in customers), "enter", ) m.addConstr(quicksum(x[depot, j] for j in customers) == K, "dep_out") m.addConstr(quicksum(x[i, depot] for i in customers) == K, "dep_in") m.addConstrs( ( u[i] - u[j] + Q * x[i, j] <= Q - demand[j] for i in customers for j in customers if i != j ), name="mtz", ) m.addConstr(u[depot] == 0, "depot_load") m.optimize() if m.status != GRB.OPTIMAL: raise ValueError("Gurobi failed to find an optimal solution.") sol = m.getAttr("x", x) routes = _reconstruct_routes(sol, depot, K) routes_df = _build_routes_df(routes, demand, coords, depot) fig = _plot_plne(routes, routes_df, coords, customers, depot, K) return routes_df, fig except (ValueError, TypeError) as e: raise RuntimeError(f"Error in solve_plne: {e}") except Exception as e: raise RuntimeError(f"Unexpected error in solve_plne: {e}") def _validate_plne_input(data, vehicle_capacity, num_vehicles): required_cols = {"Node", "X", "Y", "Demand"} if not isinstance(data, pd.DataFrame): raise TypeError("Input 'data' must be a pandas DataFrame.") if not required_cols.issubset(data.columns): missing = required_cols - set(data.columns) raise ValueError(f"Missing required columns in 'data': {missing}") if not isinstance(vehicle_capacity, (int, float)) or vehicle_capacity <= 0: raise ValueError("'vehicle_capacity' must be a positive number.") if not isinstance(num_vehicles, int) or num_vehicles <= 0: raise ValueError("'num_vehicles' must be a positive integer.") def _prepare_plne_data(data): coords = {int(r.Node): (r.X, r.Y) for _, r in data.iterrows()} demand = {int(r.Node): r.Demand for _, r in data.iterrows()} nodes = list(coords.keys()) depot = 0 if depot not in nodes: raise ValueError("Depot node (0) is missing from input.") customers = [i for i in nodes if i != depot] return coords, demand, nodes, depot, customers def _compute_distance_matrix(coords, nodes): return { (i, j): math.hypot(coords[i][0] - coords[j][0], coords[i][1] - coords[j][1]) for i in nodes for j in nodes if i != j } def _reconstruct_routes(sol, depot, K): starts = [j for (i, j), val in sol.items() if i == depot and val > 0.5] if len(starts) != K: raise ValueError(f"Expected {K} routes out of depot, got {len(starts)}") succ = {i: j for (i, j), val in sol.items() if i != depot and val > 0.5} routes = [] for start in starts: route = [depot, start] cur = start while cur != depot: nxt = succ.get(cur) if nxt is None: raise ValueError(f"Incomplete route starting at node {start}.") route.append(nxt) cur = nxt routes.append(route) return routes def _build_routes_df(routes, demand, coords, depot): rows = [] for ridx, route in enumerate(routes, start=1): load = sum(demand[n] for n in route if n != depot) dist = sum( math.hypot( coords[route[i]][0] - coords[route[i + 1]][0], coords[route[i]][1] - coords[route[i + 1]][1], ) for i in range(len(route) - 1) ) rows.append( { "Route": ridx, "Sequence": "→".join(str(n) for n in route), "Load": load, "Distance": dist, } ) return pd.DataFrame(rows) def _plot_plne(routes, routes_df, coords, customers, depot, K): fig, axs = plt.subplots(1, 2, figsize=(14, 6)) ax = axs[0] ax.scatter(*zip(*[coords[i] for i in customers]), c="blue", label="Customers") ax.scatter(*coords[depot], c="red", s=100, label="Depot") colors = plt.cm.get_cmap("tab10", K) for ridx, route in enumerate(routes): pts = [coords[n] for n in route] xs, ys = zip(*pts) ax.plot(xs, ys, "-o", color=colors(ridx), label=f"Route {ridx+1}") ax.set_title("Vehicle Routes") ax.legend(loc="upper right") ax2 = axs[1] bar_width = 0.35 idx = range(len(routes_df)) ax2.bar(idx, routes_df["Load"], bar_width, label="Load") ax2.bar( [i + bar_width for i in idx], routes_df["Distance"], bar_width, label="Distance" ) ax2.set_xticks([i + bar_width / 2 for i in idx]) ax2.set_xticklabels([f"R{r}" for r in routes_df["Route"]]) ax2.set_ylabel("Units / Distance") ax2.set_title("Load vs Distance per Route") ax2.legend() fig.tight_layout() return fig def solve_plne(data: pd.DataFrame, vehicle_capacity: float, num_vehicles: int): """ data: DataFrame with columns ["Node","X","Y","Demand"] vehicle_capacity: capacity Q of each vehicle num_vehicles: number of vehicles K Returns: (routes_df, fig) - routes_df: DataFrame with columns ["Route","Sequence","Load","Distance"] - fig: matplotlib.figure.Figure with the route‐map and summary bars """ try: _validate_plne_input(data, vehicle_capacity, num_vehicles) coords, demand, nodes, depot, customers = _prepare_plne_data(data) Q, K = vehicle_capacity, num_vehicles cost = _compute_distance_matrix(coords, nodes) m = Model("CVRP") m.setParam("OutputFlag", 0) x = m.addVars(cost.keys(), vtype=GRB.BINARY, name="x") u = m.addVars(nodes, lb=0, ub=Q, vtype=GRB.CONTINUOUS, name="u") m.setObjective(quicksum(cost[i, j] * x[i, j] for i, j in cost), GRB.MINIMIZE) m.addConstrs( (quicksum(x[i, j] for j in nodes if j != i) == 1 for i in customers), "leave", ) m.addConstrs( (quicksum(x[i, j] for i in nodes if i != j) == 1 for j in customers), "enter", ) m.addConstr(quicksum(x[depot, j] for j in customers) == K, "dep_out") m.addConstr(quicksum(x[i, depot] for i in customers) == K, "dep_in") m.addConstrs( ( u[i] - u[j] + Q * x[i, j] <= Q - demand[j] for i in customers for j in customers if i != j ), name="mtz", ) m.addConstr(u[depot] == 0, "depot_load") m.optimize() if m.status != GRB.OPTIMAL: raise ValueError("Gurobi failed to find an optimal solution.") sol = m.getAttr("x", x) routes = _reconstruct_routes(sol, depot, K) routes_df = _build_routes_df(routes, demand, coords, depot) fig = _plot_plne(routes, routes_df, coords, customers, depot, K) return routes_df, fig except (ValueError, TypeError) as e: raise RuntimeError(f"Error in solve_plne: {e}") except Exception as e: raise RuntimeError(f"Unexpected error in solve_plne: {e}")