#!/usr/bin/env python3

# VRP_Kapazitaetsfalle.py
"""
Kapitel Graphen: Die vergessene Dimension.

Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus.
Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour
aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,
Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:
eine schoene, kurze, guenstige - und unfahrbare.

Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse
gegen die tatsaechlichen Lademengen.

Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.
Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also
knapp, aber machbar.

Benoetigt: numpy, ortools
"""

from __future__ import annotations

import numpy as np
from ortools.constraint_solver import pywrapcp, routing_enums_pb2

# Dieselbe Instanz wie VRP_Flotten_Routing.py
BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
KAPAZITAETEN = [10, 10, 10, 10]
ANZAHL_FAHRZEUGE = 4
DEPOT = 0


def distanzmatrix(seed: int = 42) -> list[list[int]]:
    rng = np.random.default_rng(seed)
    koordinaten = rng.random((len(BEDARFE), 2)) * 100         # 100 x 100 km
    n = len(BEDARFE)
    return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
             for j in range(n)] for i in range(n)]


def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:
    """Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension."""
    distanz = distanzmatrix()
    manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)
    routing = pywrapcp.RoutingModel(manager)

    def entfernung(von_index, nach_index):
        return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]

    kosten_id = routing.RegisterTransitCallback(entfernung)
    routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)

    # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine
    # Ladung - die Fahrzeuge sind dann unendlich gross.
    if mit_kapazitaet:
        def bedarf(index):
            return BEDARFE[manager.IndexToNode(index)]

        bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
        routing.AddDimensionWithVehicleCapacity(
            bedarf_id,
            0,                      # kein Zwischenpuffer
            KAPAZITAETEN,           # Obergrenze je Fahrzeug
            True,                   # Ladung startet bei 0
            "Ladung")

    parameter = pywrapcp.DefaultRoutingSearchParameters()
    parameter.first_solution_strategy = (
        routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
    parameter.local_search_metaheuristic = (
        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    parameter.time_limit.FromSeconds(zeitlimit)

    loesung = routing.SolveWithParameters(parameter)
    if loesung is None:
        raise RuntimeError("Keine Loesung gefunden")

    touren, strecken, ladungen = [], [], []
    for fahrzeug in range(ANZAHL_FAHRZEUGE):
        index = routing.Start(fahrzeug)
        tour, strecke, ladung = [], 0, 0
        while not routing.IsEnd(index):
            knoten = manager.IndexToNode(index)
            tour.append(knoten)
            ladung += BEDARFE[knoten]
            vorher = index
            index = loesung.Value(routing.NextVar(index))
            strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)
        tour.append(manager.IndexToNode(index))
        touren.append(tour)
        strecken.append(strecke)
        ladungen.append(ladung)

    return {"touren": touren, "strecken": strecken, "ladungen": ladungen,
            "gesamtstrecke": sum(strecken)}


def pruefe(ergebnis: dict) -> list[str]:
    """Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.

    Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben
    Annahmen benutzen wie das Modell, sonst prueft sie nichts.
    """
    beanstandungen = []
    for fahrzeug, (ladung, kapazitaet) in enumerate(
            zip(ergebnis["ladungen"], KAPAZITAETEN)):
        if ladung > kapazitaet:
            beanstandungen.append(
                f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, "
                f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)")

    beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1])
    erwartet = list(range(1, len(BEDARFE)))
    if beliefert != erwartet:
        fehlend = set(erwartet) - set(beliefert)
        if fehlend:
            beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}")
    return beanstandungen


def zeige(titel: str, ergebnis: dict) -> None:
    print(f"\n{titel}")
    print(f"  Gesamtstrecke {ergebnis['gesamtstrecke']} km")
    print(f"  {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} "
          f"{'Kapazitaet':>11}")
    for i, (tour, strecke, ladung) in enumerate(
            zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])):
        markierung = "  <-- ueberladen" if ladung > KAPAZITAETEN[i] else ""
        print(f"  {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} "
              f"{KAPAZITAETEN[i]:>11}{markierung}")

    beanstandungen = pruefe(ergebnis)
    if beanstandungen:
        print("  PRUEFUNG: DURCHGEFALLEN")
        for text in beanstandungen:
            print(f"    - {text}")
    else:
        print("  PRUEFUNG: bestanden")


if __name__ == "__main__":
    print("=" * 78)
    print("  DIE VERGESSENE DIMENSION")
    print("=" * 78)
    print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, "
          f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} "
          f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.")

    ohne = plane(mit_kapazitaet=False)
    zeige("[1] Ohne Ladungsdimension", ohne)

    mit = plane(mit_kapazitaet=True)
    zeige("[2] Mit AddDimensionWithVehicleCapacity", mit)

    print("\n" + "=" * 78)
    mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"]
    print(f"Der korrekte Plan ist {mehr} km laenger "
          f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).")
    print()
    print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer")
    print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt")
    print("die unfahrbare Loesung fuer die bessere Optimierung - und den")
    print("korrekten Plan fuer schlechte Arbeit.")
    print()
    print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur")
    print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,")
    print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis")
    print("plausibel aussieht.")
    print("=" * 78)
