Skip to main content

routik_solver/
model.rs

1//! VRP data model.
2//!
3//! Ids are newtypes — nothing stringly- or `usize`-typed leaks into the public
4//! API. [`LocationId`] is the one exception: it is the index into a
5//! [`CostMatrix`](crate::matrix::CostMatrix), where `0` is the depot and
6//! `1..=n` are the stops in [`Problem::stops`] order.
7
8/// A geographic coordinate in decimal degrees (WGS84).
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct Coord {
11    pub lat: f64,
12    pub lon: f64,
13}
14
15impl Coord {
16    #[must_use]
17    pub const fn new(lat: f64, lon: f64) -> Self {
18        Self { lat, lon }
19    }
20}
21
22/// Stable, caller-facing identifier of a stop.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct StopId(pub u32);
25
26/// Stable, caller-facing identifier of a vehicle.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct VehicleId(pub u32);
29
30/// Index into a [`CostMatrix`](crate::matrix::CostMatrix).
31///
32/// `LocationId(0)` is always the depot; `LocationId(1..=n)` map to
33/// [`Problem::stops`] in order. The solver works in terms of these indices;
34/// callers never build them by hand.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
36pub struct LocationId(pub usize);
37
38impl LocationId {
39    /// The depot, always index `0`.
40    pub const DEPOT: LocationId = LocationId(0);
41}
42
43/// A `[start, end]` service window, in the same time unit as the matrix.
44///
45/// Not enforced yet by the construction heuristic — kept here so the model is
46/// stable for the upcoming time-window constraint.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct TimeWindow {
49    pub start: f64,
50    pub end: f64,
51}
52
53/// A delivery stop: where to go, how much to drop, and when.
54#[derive(Debug, Clone, PartialEq)]
55pub struct Stop {
56    pub id: StopId,
57    pub coord: Coord,
58    /// Units of demand to drop at this stop.
59    pub demand: u32,
60    /// Optional service window (not yet enforced).
61    pub time_window: Option<TimeWindow>,
62    /// Time spent servicing the stop, in the matrix time unit.
63    pub service_time: f64,
64}
65
66/// A vehicle in the (assumed homogeneous) fleet.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Vehicle {
69    pub id: VehicleId,
70    pub capacity: u32,
71}
72
73/// A capacitated VRP instance: one depot, a set of stops, a fleet.
74#[derive(Debug, Clone, PartialEq)]
75pub struct Problem {
76    pub depot: Coord,
77    pub stops: Vec<Stop>,
78    pub vehicles: Vec<Vehicle>,
79    /// Optional depot operating window. Its `end` is the latest time a vehicle
80    /// may return to the depot; `start` is when vehicles may leave. `None`
81    /// means the depot imposes no temporal bound (pure CVRP behaviour).
82    pub depot_window: Option<TimeWindow>,
83}
84
85impl Problem {
86    /// Number of locations including the depot (`stops.len() + 1`).
87    #[must_use]
88    pub fn location_count(&self) -> usize {
89        self.stops.len() + 1
90    }
91
92    /// All coordinates indexed by [`LocationId`]: depot first, then stops.
93    #[must_use]
94    pub fn coords(&self) -> Vec<Coord> {
95        let mut coords = Vec::with_capacity(self.location_count());
96        coords.push(self.depot);
97        coords.extend(self.stops.iter().map(|s| s.coord));
98        coords
99    }
100
101    /// Earliest time a vehicle may leave the depot (`0` if unconstrained).
102    #[must_use]
103    pub fn depot_open(&self) -> f64 {
104        self.depot_window.map_or(0.0, |w| w.start)
105    }
106
107    /// Latest time a vehicle may return to the depot (`+∞` if unconstrained).
108    #[must_use]
109    pub fn depot_due(&self) -> f64 {
110        self.depot_window.map_or(f64::INFINITY, |w| w.end)
111    }
112}
113
114/// One vehicle's tour: the ordered stops it serves and its rolled-up metrics.
115///
116/// The depot is implicit at both ends (`depot → stop_ids → depot`); it is not
117/// listed in `stop_ids`.
118#[derive(Debug, Clone, PartialEq)]
119pub struct Route {
120    pub vehicle_id: VehicleId,
121    pub stop_ids: Vec<StopId>,
122    /// Total demand carried; never exceeds the vehicle capacity.
123    pub load: u32,
124    pub distance: f64,
125    pub time: f64,
126}
127
128/// A complete assignment of stops to routes, plus rolled-up totals and the
129/// stops the solver could not place.
130#[derive(Debug, Clone, PartialEq)]
131pub struct Solution {
132    pub routes: Vec<Route>,
133    pub total_distance: f64,
134    pub total_time: f64,
135    /// True iff every hard constraint (capacity, time windows) holds **and**
136    /// every stop was assigned to a route. A solution carrying
137    /// [`unassigned`](Self::unassigned) stops is never `feasible`, even though
138    /// the routes it did build are each individually valid.
139    pub feasible: bool,
140    /// Stops the solver could not place, each with an indicative reason. Empty on
141    /// a fully-solved instance.
142    pub unassigned: Vec<Unassigned>,
143}
144
145/// A stop the solver could not place on any route, with an indicative reason.
146///
147/// The reason is a **diagnostic, not a proof**: it reports the constraint that
148/// blocked the last attempt to place the stop. A stop reported as unassigned may
149/// still be placeable under a different (e.g. larger or differently-ordered)
150/// fleet assignment; conversely the reported `code` is the dominant blocker the
151/// solver observed, not necessarily the only one.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Unassigned {
154    /// The stop that stayed unassigned.
155    pub stop: StopId,
156    /// Machine-readable cause.
157    pub code: UnassignedCode,
158    /// Human-readable explanation of the blocking constraint.
159    pub detail: String,
160}
161
162/// Why a stop could not be placed. Stable, machine-readable diagnostic codes.
163///
164/// [`MissingSkill`](Self::MissingSkill) and [`MaxTravelTime`](Self::MaxTravelTime)
165/// are **reserved**: the current model has neither skills nor a per-vehicle
166/// travel-time bound, so the solver never emits them yet. They are part of the
167/// enum so the contract is stable for when those constraints land.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum UnassignedCode {
170    /// The stop cannot be reached, serviced within its window, and returned
171    /// before the depot closes — even alone on an otherwise empty route.
172    TimeWindowInfeasible,
173    /// The stop's demand exceeds the capacity of the (smallest) vehicle, so it
174    /// fits on no route on its own.
175    CapacityExceeded,
176    /// The stop requires a skill no vehicle provides. *Reserved* — not emitted
177    /// yet (the model has no skills).
178    MissingSkill,
179    /// Serving the stop would exceed a vehicle's maximum travel time. *Reserved*
180    /// — not emitted yet (the model has no max-travel bound).
181    MaxTravelTime,
182    /// Every vehicle is already assigned and none of the built routes can absorb
183    /// the stop — the fleet is exhausted.
184    NoCompatibleVehicle,
185}
186
187impl UnassignedCode {
188    /// The stable snake_case wire form of the code.
189    #[must_use]
190    pub const fn as_str(self) -> &'static str {
191        match self {
192            UnassignedCode::TimeWindowInfeasible => "time_window_infeasible",
193            UnassignedCode::CapacityExceeded => "capacity_exceeded",
194            UnassignedCode::MissingSkill => "missing_skill",
195            UnassignedCode::MaxTravelTime => "max_travel_time",
196            UnassignedCode::NoCompatibleVehicle => "no_compatible_vehicle",
197        }
198    }
199}