rairo commited on
Commit
b90c725
·
verified ·
1 Parent(s): fa10607

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +72 -44
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 # target 0.4.1 semantics
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-1.5-flash")
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*($begin:math:display$[\\s\\S]*?$end:math:display$|\{[\s\S]*?\})\s*```", response_text, re.IGNORECASE)
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 (simple proportional split)
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
- # ---- FIX: large horizon + per-vehicle route-duration cap via dimension span ----
571
- HORIZON = 24 * 60 # full day
 
 
 
 
 
572
  routing.AddDimension(
573
  transit_idx,
574
- 30, # waiting/slack allowance
575
- HORIZON, # big horizon so market windows up to 17:00 are feasible
576
- False, # don't force start at zero
577
  "Time",
578
  )
579
  time_dim = routing.GetDimensionOrDie("Time")
580
 
581
- # Depot (hub) windows for ALL vehicles
582
  for v_id in range(len(vehs)):
583
- time_dim.CumulVar(routing.Start(v_id)).SetRange(0, HORIZON)
584
- time_dim.CumulVar(routing.End(v_id)).SetRange(0, HORIZON)
 
 
 
 
 
 
 
585
 
586
- # Market time windows (e.g., 08:00–17:00)
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(manager.NodeToIndex(node)).SetRange(int(s), int(e))
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(10)
616
  params.log_search = False
617
 
618
- # Extra debug to diagnose feasibility quickly
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
- return _empty_hub_result(
628
- hub["id"],
629
- note=f"No solution in time limit (HORIZON={HORIZON}, max_route={max_route})."
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({"type": "hub", "id": hub["id"], "name": hub["name"], "arrive_min": int(arriv)})
 
 
 
 
 
 
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)