Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
Research and competition platform for the **Multi-Product Vehicle Routing
Problem with Split Deliveries and Changeover Costs**.

In this benchmark, a changeover cost represents the operational preparation
associated with loading a product. It may apply before the first trip as well
as between successive trips; it is not limited to the act of switching from
one product to another.

The repository deliberately separates two deployable surfaces:

- the static GitHub Pages frontend at the repository root and in `pages/`;
Expand All @@ -20,8 +25,14 @@ The interactive route visualizer remains a standalone canvas application in
demands, but replaces every transition cost by zero.

The official score is the sum of `distance_total + total_switch_cost` across the
150 original-cost instances. A missing or infeasible solution receives a penalty
of `100000`.
150 original-cost instances. A ZIP submission may contain any subset of those
solutions. Every recognized file is evaluated independently; missing, unresolved,
unreadable and infeasible solutions all receive the same penalty of `100000`.

A vehicle may visit a station at most once for a given product across its entire
schedule. It may return to the same station on another trip only when serving a
different product. Split deliveries for one station-product pair must therefore
be shared between distinct vehicles.

See [`docs/problem.md`](docs/problem.md),
[`docs/instance_format.md`](docs/instance_format.md), and
Expand All @@ -36,7 +47,7 @@ backend/
core/model/ instance/solution parsing and strict feasibility checks
core/scoring/ secure ZIP ingestion and official evaluation
core/experiments/ paired scenarios and ex-post changeover repricing
database/ Notion persistence adapter
database/ participant and scoreboard persistence
data/instances/ paired benchmark datasets
docs/ Markdown sources used by the static documentation pages
pages/ GitHub Pages UI and JavaScript clients
Expand All @@ -61,14 +72,8 @@ Configure deployments with:
FRONTEND_DEV_URL=http://127.0.0.1:5500
FRONTEND_PROD_URL=https://your-org.github.io
FRONTEND_PROD_URL_2=https://your-custom-domain.example
NOTION_TOKEN=secret_...
NOTION_DATABASE_ID=...
NOTION_DATA_SOURCE_ID=...
```

Notion remains the source of truth for participant score, feasible-solution
count, submission date and rank.

## Static frontend

The frontend has no build requirement. Serve the repository root with any static
Expand Down Expand Up @@ -96,7 +101,7 @@ uv run pytest
```

The suite validates the API, strict solution checks, generator, ZIP safety,
Notion adapter and all 150 paired benchmark files.
scoreboard persistence and all 150 paired benchmark files.

## Docker

Expand Down
6 changes: 3 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This directory is the complete server-side boundary of MPVRP-CC.
- `core/model/` owns canonical parsers and solution feasibility.
- `core/scoring/` evaluates submitted archives against official instances.
- `core/experiments/` creates and reprices paired cost scenarios.
- `database/` is the Notion persistence adapter.
- `database/` stores participant and scoreboard information.

Domain code does not depend on the static frontend. The frontend communicates
with it only through the routes declared in `app/main.py`.
The server and the static website remain independent. They communicate through
the public HTTP API.
2 changes: 1 addition & 1 deletion backend/app/routes/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async def verify_solution_endpoint(
- Weight maintenance (quantity loaded = quantity delivered)
- Meeting the demand of all stations
- Compliance with depot stock levels
- Metric validation
- Automatic recomputation of distance and transition metrics

Returns:

Expand Down
28 changes: 21 additions & 7 deletions backend/core/model/feasibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@
METRIC_TOLERANCE = 0.2


def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> tuple[list[str], dict[str, Any]]:
def verify_solution(
instance: Instance,
solution: ParsedSolutionDat,
*,
check_reported_metrics: bool = False,
) -> tuple[list[str], dict[str, Any]]:
"""Check route feasibility and recompute all performance metrics.

Values reported in the solution file are informative by default: rounding
or stale summary values must not invalidate an otherwise feasible route.
They can still be audited explicitly with ``check_reported_metrics=True``.
"""
errors: list[str] = []
vehicles = {int(key[1:]): value for key, value in instance.camions.items()}
depots = {int(key[1:]): value for key, value in instance.depots.items()}
Expand Down Expand Up @@ -44,7 +55,9 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> tuple[li
)
continue
keys = [solution_node_key(node["kind"], node["id"]) for node in route.nodes]
expected_garage = vehicle.garage_id
expected_garage = str(vehicle.garage_id)
if not expected_garage.startswith("G"):
expected_garage = f"G{expected_garage}"
if not keys:
errors.append(f"Vehicle {vehicle_id}: empty route")
continue
Expand Down Expand Up @@ -145,7 +158,7 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> tuple[li
trip_has_station = True

expected_cumulative = float(cumulative_costs[index])
if abs(expected_cumulative - cumulative) > METRIC_TOLERANCE:
if check_reported_metrics and abs(expected_cumulative - cumulative) > METRIC_TOLERANCE:
errors.append(
f"Vehicle {vehicle_id}: cumulative changeover cost at step {index + 1} "
f"is {expected_cumulative}, expected {cumulative:.2f}"
Expand Down Expand Up @@ -177,10 +190,11 @@ def verify_solution(instance: Instance, solution: ParsedSolutionDat) -> tuple[li
"total_switch_cost": total_switch_cost,
"distance_total": total_distance,
}
_check_metric(errors, solution.metrics, computed, "used_vehicles", 0)
_check_metric(errors, solution.metrics, computed, "total_changes", 0)
_check_metric(errors, solution.metrics, computed, "total_switch_cost", METRIC_TOLERANCE)
_check_metric(errors, solution.metrics, computed, "distance_total", METRIC_TOLERANCE)
if check_reported_metrics:
_check_metric(errors, solution.metrics, computed, "used_vehicles", 0)
_check_metric(errors, solution.metrics, computed, "total_changes", 0)
_check_metric(errors, solution.metrics, computed, "total_switch_cost", METRIC_TOLERANCE)
_check_metric(errors, solution.metrics, computed, "distance_total", METRIC_TOLERANCE)
return errors, computed


Expand Down
13 changes: 7 additions & 6 deletions backend/core/model/modelisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def __init__(self, instance: Instance, max_positions: int = None, time_limit: in

self.C = {c.id : c.capacity for c in instance.camions.values()}
self.g_k = {c.id : c.garage_id for c in instance.camions.values()}
self.p_initial = {c.id : c.initial_product for c in instance.camions.values()}
# The optimization model uses products 1..P while domain objects use 0..P-1.
self.p_initial = {c.id: c.initial_product + 1 for c in instance.camions.values()}

# IMPORTANT: dans `utils.parse_instance`, les clés produits des demandes/stocks sont 0..(P-1)
# alors que le modèle utilise 1..P. On décale donc de +1 ici pour rester cohérent.
Expand Down Expand Up @@ -502,11 +503,11 @@ def _val(var) -> float:

# garage de départ
line1_parts.append(f"{mapping.get(garage_str, 0)}")
# On aligne la ligne produit sur le produit de la 1ère mini-tournée (format README)
first_tour_product_export = (k_tours[0]["product"] - 1) if k_tours else self.p_initial[k]
line2_parts.append(f"{first_tour_product_export}({current_cumul_cost:.1f})")
# The departure garage records the vehicle's initial configuration.
initial_product_export = self.p_initial[k] - 1
line2_parts.append(f"{initial_product_export}({current_cumul_cost:.1f})")

last_product_export = first_tour_product_export
last_product_export = initial_product_export
last_station = None
last_t = None

Expand Down Expand Up @@ -590,4 +591,4 @@ def _val(var) -> float:
solver = Solver(instance)
solver.solve()
print(solver.solution)
solver.export_solution(solution_path)
solver.export_solution(solution_path)
49 changes: 36 additions & 13 deletions backend/core/model/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,14 @@ def _parse_solution_route_token(token: str) -> Dict[str, Any]:
# Dépôt : format id[qty]
left, right = token.split("[", 1)
node_id = int(left.strip())
qty = int(right.split("]", 1)[0].strip())
qty = float(right.split("]", 1)[0].strip())
return {"kind": "depot", "id": node_id, "qty": qty}

if "(" in token and ")" in token:
# Station : format id(qty)
left, right = token.split("(", 1)
node_id = int(left.strip())
qty = int(right.split(")", 1)[0].strip())
qty = float(right.split(")", 1)[0].strip())
return {"kind": "station", "id": node_id, "qty": qty}

# Garage : format id (pas de quantité)
Expand All @@ -187,6 +187,11 @@ def _parse_solution_product_token(token: str) -> Tuple[int, float]:
token = token.strip()
if not token:
raise ValueError("Empty token")
if "(" not in token and ")" not in token:
try:
return int(token), 0.0
except ValueError as exc:
raise ValueError(f"Invalid product token: {token}") from exc
if "(" not in token or ")" not in token:
raise ValueError(f"Invalid product token: {token}")
p_str, rest = token.split("(", 1)
Expand Down Expand Up @@ -275,6 +280,18 @@ def _is_vehicle_line(line: str) -> bool:
prod_tokens = [t for t in prod_tokens if t]
products = [_parse_solution_product_token(t) for t in prod_tokens]

# Historical benchmark solutions omit the product entry associated
# with the final return to the garage. The vehicle keeps the product
# configuration and cumulative cost of the preceding step there, so
# expand that compact representation to the canonical node-aligned
# form used by the feasibility checker.
if (
len(products) == len(nodes) - 1
and products
and nodes[-1]["kind"] == "garage"
):
products.append(products[-1])

vehicles.append(ParsedSolutionVehicle(vehicle_id=vehicle_id, nodes=nodes, products=products))

i += 2
Expand All @@ -284,16 +301,22 @@ def _is_vehicle_line(line: str) -> bool:

# Les lignes non vides restantes contiennent les métriques (6 lignes)
metrics_lines = [l.strip() for l in raw_lines[i:] if l.strip()]
if len(metrics_lines) != 6:
raise ValueError(f"Expected 6 metric lines, got {len(metrics_lines)}")

metrics = {
"used_vehicles": int(metrics_lines[0]),
"total_changes": int(metrics_lines[1]),
"total_switch_cost": float(metrics_lines[2]),
"distance_total": float(metrics_lines[3]),
"processor": metrics_lines[4],
"time": float(metrics_lines[5]),
}
if len(metrics_lines) not in {0, 4, 5, 6}:
raise ValueError(
"The optional summary must be omitted or contain at least its four numeric metrics"
Comment on lines 302 to +306
)

metrics: dict[str, Any] = {}
if metrics_lines:
metrics.update({
"used_vehicles": int(metrics_lines[0]),
"total_changes": int(metrics_lines[1]),
"total_switch_cost": float(metrics_lines[2]),
"distance_total": float(metrics_lines[3]),
})
if len(metrics_lines) >= 5:
metrics["processor"] = metrics_lines[4]
if len(metrics_lines) == 6:
metrics["time"] = float(metrics_lines[5])

return ParsedSolutionDat(vehicles=vehicles, metrics=metrics)
Loading
Loading