Update main.py
Browse files
main.py
CHANGED
|
@@ -16,7 +16,7 @@ from flask import Flask, request, jsonify
|
|
| 16 |
from flask_cors import CORS
|
| 17 |
|
| 18 |
# LLM
|
| 19 |
-
import google.generativeai as genai
|
| 20 |
|
| 21 |
# OR-Tools
|
| 22 |
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
|
@@ -37,8 +37,8 @@ logger = logging.getLogger(__name__)
|
|
| 37 |
# -----------------------------------------------------------------------------
|
| 38 |
PORT = int(os.environ.get("PORT", "7860"))
|
| 39 |
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("Gemini") or ""
|
| 40 |
-
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash")
|
| 41 |
-
MODEL_FALLBACK = os.environ.get("GEMINI_MODEL_FALLBACK", "gemini-
|
| 42 |
RANDOM_SEED = int(os.environ.get("VRP_SEED", "42"))
|
| 43 |
np.random.seed(RANDOM_SEED)
|
| 44 |
|
|
@@ -67,10 +67,6 @@ MARKETS = [
|
|
| 67 |
FLEET = [
|
| 68 |
{"id":"V1","hub_id":"H2","type":"insulated","cap_kg":1400,"cost_per_km":13.5,"co2_per_km":1.1,"max_route_min":8*60},
|
| 69 |
{"id":"V2","hub_id":"H2","type":"non_insulated","cap_kg":1200,"cost_per_km":12.0,"co2_per_km":1.2,"max_route_min":8*60},
|
| 70 |
-
|
| 71 |
-
# NEW: give Pretoria North trucks so its 1,307 kg isn’t stranded
|
| 72 |
-
{"id":"V3","hub_id":"H1","type":"insulated","cap_kg":1400,"cost_per_km":13.5,"co2_per_km":1.1,"max_route_min":8*60},
|
| 73 |
-
{"id":"V4","hub_id":"H1","type":"non_insulated","cap_kg":1200,"cost_per_km":12.0,"co2_per_km":1.2,"max_route_min":8*60},
|
| 74 |
]
|
| 75 |
|
| 76 |
FRESHNESS_MIN = {
|
|
@@ -191,7 +187,7 @@ def configure_gemini_model() -> genai.GenerativeModel:
|
|
| 191 |
def extract_json_from_response(response_text: str) -> Any:
|
| 192 |
if not response_text:
|
| 193 |
raise ValueError("Empty LLM response")
|
| 194 |
-
m = re.search(r"```json\s*(
|
| 195 |
if m:
|
| 196 |
return json.loads(m.group(1).strip())
|
| 197 |
m = re.search(r"\[[\s\S]*\]", response_text)
|
|
@@ -361,7 +357,6 @@ def extract():
|
|
| 361 |
logger.error(f"[extract] missing columns: {missing}")
|
| 362 |
return jsonify({"error": f"missing required columns: {missing}"}), 400
|
| 363 |
|
| 364 |
-
# Configure Gemini model (with fallback)
|
| 365 |
try:
|
| 366 |
model = configure_gemini_model()
|
| 367 |
except Exception as e:
|
|
@@ -388,7 +383,6 @@ def extract():
|
|
| 388 |
|
| 389 |
try:
|
| 390 |
parsed = call_gemini_with_retry_extract(model, user_payload, retries=3)
|
| 391 |
-
# debug preview
|
| 392 |
try:
|
| 393 |
preview = json.dumps(parsed)[:300]
|
| 394 |
logger.debug(f"[extract] batch parsed preview: {preview}")
|
|
@@ -526,7 +520,7 @@ def plan():
|
|
| 526 |
return jsonify({"error":"Internal server error"}), 500
|
| 527 |
|
| 528 |
# -----------------------------------------------------------------------------
|
| 529 |
-
# OR-Tools VRP (hub -> markets)
|
| 530 |
# -----------------------------------------------------------------------------
|
| 531 |
def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
| 532 |
nodes = [hub] + markets
|
|
@@ -546,7 +540,7 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 546 |
dist_km[i, j] = d
|
| 547 |
time_min[i, j] = travel_min(d, rainy=rainy)
|
| 548 |
|
| 549 |
-
# Demand scaling
|
| 550 |
dem = [0.0] + [sum(m["demand"].values()) for m in markets]
|
| 551 |
total_dem = sum(dem)
|
| 552 |
if total_dem <= 0:
|
|
@@ -567,34 +561,42 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 567 |
transit_idx = routing.RegisterTransitCallback(time_cb)
|
| 568 |
routing.SetArcCostEvaluatorOfAllVehicles(transit_idx)
|
| 569 |
|
| 570 |
-
#
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
routing.AddDimension(
|
| 573 |
transit_idx,
|
| 574 |
-
|
| 575 |
-
HORIZON, #
|
| 576 |
-
False, # don't force
|
| 577 |
"Time",
|
| 578 |
)
|
| 579 |
time_dim = routing.GetDimensionOrDie("Time")
|
| 580 |
|
| 581 |
-
# Depot
|
| 582 |
for v_id in range(len(vehs)):
|
| 583 |
-
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
-
# Market time windows
|
| 587 |
for node in range(1, n):
|
|
|
|
| 588 |
s = markets[node - 1]["open_start"]
|
| 589 |
e = markets[node - 1]["open_end"]
|
| 590 |
-
time_dim.CumulVar(
|
| 591 |
-
|
| 592 |
-
# Cap each vehicle's working time using the dimension's span bound (CORRECT API)
|
| 593 |
-
max_route = int(max(v["max_route_min"] for v in vehs))
|
| 594 |
-
for v_id in range(len(vehs)):
|
| 595 |
-
time_dim.SetSpanUpperBoundForVehicle(max_route, v_id)
|
| 596 |
|
| 597 |
-
# Capacity (kg)
|
| 598 |
def demand_cb(from_idx):
|
| 599 |
i = manager.IndexToNode(from_idx)
|
| 600 |
return int(demand_deliver[i])
|
|
@@ -602,32 +604,35 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 602 |
dem_idx = routing.RegisterUnaryTransitCallback(demand_cb)
|
| 603 |
routing.AddDimensionWithVehicleCapacity(
|
| 604 |
dem_idx,
|
| 605 |
-
0,
|
| 606 |
[int(v["cap_kg"]) for v in vehs],
|
| 607 |
True,
|
| 608 |
"Capacity",
|
| 609 |
)
|
| 610 |
|
| 611 |
-
# Search params
|
| 612 |
params = pywrapcp.DefaultRoutingSearchParameters()
|
| 613 |
params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
| 614 |
params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
|
| 615 |
-
params.time_limit.FromSeconds(
|
| 616 |
params.log_search = False
|
| 617 |
|
| 618 |
-
|
| 619 |
-
try:
|
| 620 |
-
tws = [(m["open_start"], m["open_end"]) for m in markets]
|
| 621 |
-
logging.debug(f"[vrp] HORIZON={HORIZON}, max_route={max_route}, windows={tws}")
|
| 622 |
-
except Exception:
|
| 623 |
-
pass
|
| 624 |
|
| 625 |
sol = routing.SolveWithParameters(params)
|
| 626 |
if not sol:
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
|
| 632 |
# Build routes & KPIs
|
| 633 |
total_km = 0.0
|
|
@@ -641,6 +646,7 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 641 |
idx = routing.Start(v_id)
|
| 642 |
rnodes = []
|
| 643 |
route_km = 0.0
|
|
|
|
| 644 |
while not routing.IsEnd(idx):
|
| 645 |
node = manager.IndexToNode(idx)
|
| 646 |
tmin = sol.Value(time_dim.CumulVar(idx))
|
|
@@ -650,8 +656,14 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 650 |
nxt = manager.IndexToNode(next_idx)
|
| 651 |
route_km += dist_km[node, nxt]
|
| 652 |
idx = next_idx
|
|
|
|
|
|
|
| 653 |
rnodes.append((manager.IndexToNode(idx), sol.Value(time_dim.CumulVar(idx))))
|
| 654 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
route_cost = route_km * veh["cost_per_km"]
|
| 656 |
route_em = route_km * veh["co2_per_km"]
|
| 657 |
total_km += route_km
|
|
@@ -659,12 +671,16 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 659 |
total_em += route_em
|
| 660 |
|
| 661 |
insulated = (veh["type"] == "insulated")
|
|
|
|
|
|
|
| 662 |
for node, arriv in rnodes:
|
| 663 |
-
if node == 0:
|
| 664 |
continue
|
| 665 |
mk = markets[node - 1]
|
| 666 |
qty = demand_deliver[node]
|
| 667 |
delivered += qty
|
|
|
|
|
|
|
| 668 |
totm = sum(mk["demand"].values()) + 1e-6
|
| 669 |
viol_share = 0.0
|
| 670 |
for crop, demc in mk["demand"].items():
|
|
@@ -674,10 +690,21 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 674 |
viol_share += share
|
| 675 |
freshness_viol += qty * viol_share
|
| 676 |
|
|
|
|
| 677 |
pretty = []
|
| 678 |
for node, arriv in rnodes:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
if node == 0:
|
| 680 |
-
pretty.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
else:
|
| 682 |
mk = markets[node - 1]
|
| 683 |
pretty.append({
|
|
@@ -685,6 +712,7 @@ def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
|
| 685 |
"id": mk["id"],
|
| 686 |
"name": mk["name"],
|
| 687 |
"arrive_min": int(arriv),
|
|
|
|
| 688 |
"deliver_kg": round(demand_deliver[node], 1)
|
| 689 |
})
|
| 690 |
|
|
@@ -735,4 +763,4 @@ if __name__ == "__main__":
|
|
| 735 |
logger.warning("[boot] GEMINI_API_KEY not set")
|
| 736 |
|
| 737 |
logger.info(f"[boot] starting Flask on 0.0.0.0:{PORT}")
|
| 738 |
-
app.run(host="0.0.0.0", port=PORT, debug=True)
|
|
|
|
| 16 |
from flask_cors import CORS
|
| 17 |
|
| 18 |
# LLM
|
| 19 |
+
import google.generativeai as genai
|
| 20 |
|
| 21 |
# OR-Tools
|
| 22 |
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
|
|
|
| 37 |
# -----------------------------------------------------------------------------
|
| 38 |
PORT = int(os.environ.get("PORT", "7860"))
|
| 39 |
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("Gemini") or ""
|
| 40 |
+
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash-exp")
|
| 41 |
+
MODEL_FALLBACK = os.environ.get("GEMINI_MODEL_FALLBACK", "gemini-2.0-flash")
|
| 42 |
RANDOM_SEED = int(os.environ.get("VRP_SEED", "42"))
|
| 43 |
np.random.seed(RANDOM_SEED)
|
| 44 |
|
|
|
|
| 67 |
FLEET = [
|
| 68 |
{"id":"V1","hub_id":"H2","type":"insulated","cap_kg":1400,"cost_per_km":13.5,"co2_per_km":1.1,"max_route_min":8*60},
|
| 69 |
{"id":"V2","hub_id":"H2","type":"non_insulated","cap_kg":1200,"cost_per_km":12.0,"co2_per_km":1.2,"max_route_min":8*60},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
]
|
| 71 |
|
| 72 |
FRESHNESS_MIN = {
|
|
|
|
| 187 |
def extract_json_from_response(response_text: str) -> Any:
|
| 188 |
if not response_text:
|
| 189 |
raise ValueError("Empty LLM response")
|
| 190 |
+
m = re.search(r"```json\s*(\[[\s\S]*?\]|\{[\s\S]*?\})\s*```", response_text, re.IGNORECASE)
|
| 191 |
if m:
|
| 192 |
return json.loads(m.group(1).strip())
|
| 193 |
m = re.search(r"\[[\s\S]*\]", response_text)
|
|
|
|
| 357 |
logger.error(f"[extract] missing columns: {missing}")
|
| 358 |
return jsonify({"error": f"missing required columns: {missing}"}), 400
|
| 359 |
|
|
|
|
| 360 |
try:
|
| 361 |
model = configure_gemini_model()
|
| 362 |
except Exception as e:
|
|
|
|
| 383 |
|
| 384 |
try:
|
| 385 |
parsed = call_gemini_with_retry_extract(model, user_payload, retries=3)
|
|
|
|
| 386 |
try:
|
| 387 |
preview = json.dumps(parsed)[:300]
|
| 388 |
logger.debug(f"[extract] batch parsed preview: {preview}")
|
|
|
|
| 520 |
return jsonify({"error":"Internal server error"}), 500
|
| 521 |
|
| 522 |
# -----------------------------------------------------------------------------
|
| 523 |
+
# OR-Tools VRP (hub -> markets) - FIXED VERSION
|
| 524 |
# -----------------------------------------------------------------------------
|
| 525 |
def solve_hub_to_markets(hub, fleet, markets, supply_kg, rainy=False):
|
| 526 |
nodes = [hub] + markets
|
|
|
|
| 540 |
dist_km[i, j] = d
|
| 541 |
time_min[i, j] = travel_min(d, rainy=rainy)
|
| 542 |
|
| 543 |
+
# Demand scaling
|
| 544 |
dem = [0.0] + [sum(m["demand"].values()) for m in markets]
|
| 545 |
total_dem = sum(dem)
|
| 546 |
if total_dem <= 0:
|
|
|
|
| 561 |
transit_idx = routing.RegisterTransitCallback(time_cb)
|
| 562 |
routing.SetArcCostEvaluatorOfAllVehicles(transit_idx)
|
| 563 |
|
| 564 |
+
# FIX: Use earliest market start as depot start, latest as overall horizon
|
| 565 |
+
earliest_start = min(m["open_start"] for m in markets)
|
| 566 |
+
latest_end = max(m["open_end"] for m in markets)
|
| 567 |
+
DEPOT_START = max(0, earliest_start - 60) # depot opens 1hr before first market
|
| 568 |
+
HORIZON = latest_end + 60 # extend 1hr past last market close
|
| 569 |
+
|
| 570 |
+
# Time dimension with proper horizon
|
| 571 |
routing.AddDimension(
|
| 572 |
transit_idx,
|
| 573 |
+
60, # slack for waiting/service time
|
| 574 |
+
HORIZON, # overall horizon
|
| 575 |
+
False, # don't force cumul to zero at start
|
| 576 |
"Time",
|
| 577 |
)
|
| 578 |
time_dim = routing.GetDimensionOrDie("Time")
|
| 579 |
|
| 580 |
+
# Depot windows: vehicles can leave anytime from DEPOT_START to HORIZON
|
| 581 |
for v_id in range(len(vehs)):
|
| 582 |
+
depot_start_idx = routing.Start(v_id)
|
| 583 |
+
depot_end_idx = routing.End(v_id)
|
| 584 |
+
time_dim.CumulVar(depot_start_idx).SetRange(DEPOT_START, HORIZON)
|
| 585 |
+
time_dim.CumulVar(depot_end_idx).SetRange(DEPOT_START, HORIZON)
|
| 586 |
+
|
| 587 |
+
# Per-vehicle route duration limit (from depot departure to depot return)
|
| 588 |
+
max_route = vehs[v_id]["max_route_min"]
|
| 589 |
+
time_dim.SetSpanCostCoefficientForVehicle(10000, v_id) # penalize long routes
|
| 590 |
+
time_dim.SetSpanUpperBoundForVehicle(max_route, v_id)
|
| 591 |
|
| 592 |
+
# Market time windows
|
| 593 |
for node in range(1, n):
|
| 594 |
+
idx = manager.NodeToIndex(node)
|
| 595 |
s = markets[node - 1]["open_start"]
|
| 596 |
e = markets[node - 1]["open_end"]
|
| 597 |
+
time_dim.CumulVar(idx).SetRange(int(s), int(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
|
| 599 |
+
# Capacity dimension (kg)
|
| 600 |
def demand_cb(from_idx):
|
| 601 |
i = manager.IndexToNode(from_idx)
|
| 602 |
return int(demand_deliver[i])
|
|
|
|
| 604 |
dem_idx = routing.RegisterUnaryTransitCallback(demand_cb)
|
| 605 |
routing.AddDimensionWithVehicleCapacity(
|
| 606 |
dem_idx,
|
| 607 |
+
0, # no slack
|
| 608 |
[int(v["cap_kg"]) for v in vehs],
|
| 609 |
True,
|
| 610 |
"Capacity",
|
| 611 |
)
|
| 612 |
|
| 613 |
+
# Search params - optimized for faster solving
|
| 614 |
params = pywrapcp.DefaultRoutingSearchParameters()
|
| 615 |
params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
| 616 |
params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
|
| 617 |
+
params.time_limit.FromSeconds(15) # increased from 10s for better solutions
|
| 618 |
params.log_search = False
|
| 619 |
|
| 620 |
+
logger.debug(f"[vrp] hub={hub['id']} DEPOT_START={DEPOT_START} HORIZON={HORIZON} vehicles={len(vehs)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
|
| 622 |
sol = routing.SolveWithParameters(params)
|
| 623 |
if not sol:
|
| 624 |
+
# Try with relaxed constraints if initial solve fails
|
| 625 |
+
logger.warning(f"[vrp] No solution for hub {hub['id']}, trying with dropped visits")
|
| 626 |
+
for node in range(1, n):
|
| 627 |
+
idx = manager.NodeToIndex(node)
|
| 628 |
+
routing.AddDisjunction([idx], 10000) # allow dropping with penalty
|
| 629 |
+
|
| 630 |
+
sol = routing.SolveWithParameters(params)
|
| 631 |
+
if not sol:
|
| 632 |
+
return _empty_hub_result(
|
| 633 |
+
hub["id"],
|
| 634 |
+
note=f"No feasible solution found (depot_start={DEPOT_START}, horizon={HORIZON})"
|
| 635 |
+
)
|
| 636 |
|
| 637 |
# Build routes & KPIs
|
| 638 |
total_km = 0.0
|
|
|
|
| 646 |
idx = routing.Start(v_id)
|
| 647 |
rnodes = []
|
| 648 |
route_km = 0.0
|
| 649 |
+
|
| 650 |
while not routing.IsEnd(idx):
|
| 651 |
node = manager.IndexToNode(idx)
|
| 652 |
tmin = sol.Value(time_dim.CumulVar(idx))
|
|
|
|
| 656 |
nxt = manager.IndexToNode(next_idx)
|
| 657 |
route_km += dist_km[node, nxt]
|
| 658 |
idx = next_idx
|
| 659 |
+
|
| 660 |
+
# Add final depot return
|
| 661 |
rnodes.append((manager.IndexToNode(idx), sol.Value(time_dim.CumulVar(idx))))
|
| 662 |
|
| 663 |
+
# Skip empty routes
|
| 664 |
+
if len(rnodes) <= 2: # only depot start and end
|
| 665 |
+
continue
|
| 666 |
+
|
| 667 |
route_cost = route_km * veh["cost_per_km"]
|
| 668 |
route_em = route_km * veh["co2_per_km"]
|
| 669 |
total_km += route_km
|
|
|
|
| 671 |
total_em += route_em
|
| 672 |
|
| 673 |
insulated = (veh["type"] == "insulated")
|
| 674 |
+
|
| 675 |
+
# Calculate delivery and freshness violations
|
| 676 |
for node, arriv in rnodes:
|
| 677 |
+
if node == 0: # skip depot
|
| 678 |
continue
|
| 679 |
mk = markets[node - 1]
|
| 680 |
qty = demand_deliver[node]
|
| 681 |
delivered += qty
|
| 682 |
+
|
| 683 |
+
# Freshness calculation
|
| 684 |
totm = sum(mk["demand"].values()) + 1e-6
|
| 685 |
viol_share = 0.0
|
| 686 |
for crop, demc in mk["demand"].items():
|
|
|
|
| 690 |
viol_share += share
|
| 691 |
freshness_viol += qty * viol_share
|
| 692 |
|
| 693 |
+
# Build pretty route for output
|
| 694 |
pretty = []
|
| 695 |
for node, arriv in rnodes:
|
| 696 |
+
h = arriv // 60
|
| 697 |
+
m = arriv % 60
|
| 698 |
+
time_str = f"{int(h):02d}:{int(m):02d}"
|
| 699 |
+
|
| 700 |
if node == 0:
|
| 701 |
+
pretty.append({
|
| 702 |
+
"type": "hub",
|
| 703 |
+
"id": hub["id"],
|
| 704 |
+
"name": hub["name"],
|
| 705 |
+
"arrive_min": int(arriv),
|
| 706 |
+
"arrive_time": time_str
|
| 707 |
+
})
|
| 708 |
else:
|
| 709 |
mk = markets[node - 1]
|
| 710 |
pretty.append({
|
|
|
|
| 712 |
"id": mk["id"],
|
| 713 |
"name": mk["name"],
|
| 714 |
"arrive_min": int(arriv),
|
| 715 |
+
"arrive_time": time_str,
|
| 716 |
"deliver_kg": round(demand_deliver[node], 1)
|
| 717 |
})
|
| 718 |
|
|
|
|
| 763 |
logger.warning("[boot] GEMINI_API_KEY not set")
|
| 764 |
|
| 765 |
logger.info(f"[boot] starting Flask on 0.0.0.0:{PORT}")
|
| 766 |
+
app.run(host="0.0.0.0", port=PORT, debug=True)
|