Skip to main content

routik_solver/
matrix.rs

1//! Cost matrix abstraction and the Haversine (great-circle) implementation.
2//!
3//! The solver depends on the [`CostMatrix`] trait **only** — never on a
4//! concrete matrix. `HaversineMatrix` is the straight-line fallback;
5//! `ProvidedMatrix` carries caller- or OSRM-supplied numbers. OSRM is **not** a
6//! matrix type here: it is network I/O (forbidden in this pure lib), so the
7//! `routik-api` OSRM client fetches the `N×N` table once and fills a
8//! `ProvidedMatrix` the solver then consumes like any other.
9
10use crate::model::{Coord, LocationId, Problem};
11
12/// Mean Earth radius in kilometres.
13const EARTH_RADIUS_KM: f64 = 6371.0;
14
15/// Travel cost between any two locations, addressed by [`LocationId`].
16///
17/// Distance is in kilometres; time is in hours (distance / average speed).
18pub trait CostMatrix {
19    /// Great-circle / road distance between `a` and `b`, in kilometres.
20    fn distance(&self, a: LocationId, b: LocationId) -> f64;
21
22    /// Travel time between `a` and `b`, in hours.
23    fn time(&self, a: LocationId, b: LocationId) -> f64;
24}
25
26/// Straight-line (great-circle) cost matrix.
27///
28/// Distance is the Haversine distance between coordinates; time is that
29/// distance divided by a configurable average speed (km/h).
30#[derive(Debug, Clone)]
31pub struct HaversineMatrix {
32    coords: Vec<Coord>,
33    speed_kmh: f64,
34}
35
36impl HaversineMatrix {
37    /// Build a matrix over `coords`, indexed by [`LocationId`].
38    ///
39    /// `speed_kmh` is the average travel speed used to turn distance into time.
40    #[must_use]
41    pub fn new(coords: Vec<Coord>, speed_kmh: f64) -> Self {
42        Self { coords, speed_kmh }
43    }
44
45    /// Build a matrix matching a [`Problem`]'s location layout: depot at
46    /// [`LocationId::DEPOT`], then stops in order.
47    #[must_use]
48    pub fn from_problem(problem: &Problem, speed_kmh: f64) -> Self {
49        Self::new(problem.coords(), speed_kmh)
50    }
51}
52
53/// Haversine distance between two coordinates, in kilometres.
54fn haversine_km(a: Coord, b: Coord) -> f64 {
55    let lat1 = a.lat.to_radians();
56    let lat2 = b.lat.to_radians();
57    let dlat = (b.lat - a.lat).to_radians();
58    let dlon = (b.lon - a.lon).to_radians();
59
60    let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
61    // clamp guards against tiny fp overshoot above 1.0 for near-antipodal pairs
62    let c = 2.0 * h.sqrt().min(1.0).asin();
63    EARTH_RADIUS_KM * c
64}
65
66impl CostMatrix for HaversineMatrix {
67    fn distance(&self, a: LocationId, b: LocationId) -> f64 {
68        haversine_km(self.coords[a.0], self.coords[b.0])
69    }
70
71    fn time(&self, a: LocationId, b: LocationId) -> f64 {
72        self.distance(a, b) / self.speed_kmh
73    }
74}
75
76/// Planar Euclidean cost matrix.
77///
78/// Distance is the straight-line distance between planar `(x, y)` coordinates,
79/// stored in [`Coord`] as `lon = x`, `lat = y`. Following the Solomon VRPTW
80/// convention, **travel time equals distance** (unit speed), so the same
81/// numbers drive both legs and time-window arithmetic. This is the matrix the
82/// Solomon benchmark uses — never Haversine.
83#[derive(Debug, Clone)]
84pub struct EuclideanMatrix {
85    coords: Vec<Coord>,
86}
87
88impl EuclideanMatrix {
89    /// Build a matrix over planar coordinates, indexed by [`LocationId`].
90    #[must_use]
91    pub fn new(coords: Vec<Coord>) -> Self {
92        Self { coords }
93    }
94
95    /// Build a matrix matching a [`Problem`]'s location layout: depot at
96    /// [`LocationId::DEPOT`], then stops in order.
97    #[must_use]
98    pub fn from_problem(problem: &Problem) -> Self {
99        Self::new(problem.coords())
100    }
101}
102
103/// Straight-line distance between two planar points (`lon = x`, `lat = y`).
104fn euclidean(a: Coord, b: Coord) -> f64 {
105    let dx = a.lon - b.lon;
106    let dy = a.lat - b.lat;
107    dx.hypot(dy)
108}
109
110impl CostMatrix for EuclideanMatrix {
111    fn distance(&self, a: LocationId, b: LocationId) -> f64 {
112        euclidean(self.coords[a.0], self.coords[b.0])
113    }
114
115    fn time(&self, a: LocationId, b: LocationId) -> f64 {
116        // Solomon convention: unit speed, so time == distance.
117        self.distance(a, b)
118    }
119}
120
121/// Caller-supplied cost matrix: explicit distance and time between every pair
122/// of locations, addressed by [`LocationId`] (`0` = depot, `1..=n` = stops).
123///
124/// This is the matrix used when the caller brings their own routing numbers
125/// (e.g. from a maps engine) instead of relying on the straight-line
126/// [`HaversineMatrix`]. Both inner matrices are square and share the same `n`.
127#[derive(Debug, Clone)]
128pub struct ProvidedMatrix {
129    n: usize,
130    distances: Vec<f64>, // row-major n×n
131    times: Vec<f64>,     // row-major n×n
132}
133
134/// Why a [`ProvidedMatrix`] could not be built from caller input.
135#[derive(Debug, thiserror::Error, PartialEq, Eq)]
136pub enum MatrixError {
137    /// A matrix was empty or not square (a row's length ≠ the number of rows).
138    #[error("matrix must be a non-empty square: {rows} rows but a row of {row_len}")]
139    NotSquare { rows: usize, row_len: usize },
140    /// The distance and time matrices have different dimensions.
141    #[error("distance matrix is {distance}×{distance} but time matrix is {time}×{time}")]
142    SizeMismatch { distance: usize, time: usize },
143}
144
145impl ProvidedMatrix {
146    /// Build from row-major `distances` and `times`, each an `n×n` matrix whose
147    /// rows and columns are indexed by [`LocationId`] (depot first, then stops).
148    ///
149    /// # Errors
150    /// [`MatrixError::NotSquare`] if either matrix is empty or any row's length
151    /// differs from the row count; [`MatrixError::SizeMismatch`] if the two
152    /// matrices disagree on `n`.
153    pub fn new(distances: Vec<Vec<f64>>, times: Vec<Vec<f64>>) -> Result<Self, MatrixError> {
154        let (n_d, distances) = Self::flatten(distances)?;
155        let (n_t, times) = Self::flatten(times)?;
156        if n_d != n_t {
157            return Err(MatrixError::SizeMismatch {
158                distance: n_d,
159                time: n_t,
160            });
161        }
162        Ok(Self {
163            n: n_d,
164            distances,
165            times,
166        })
167    }
168
169    /// Materialise any [`CostMatrix`] into a dense `n×n` store, indexed by
170    /// [`LocationId`] `0..n`.
171    ///
172    /// The local search queries `distance`/`time` for the same pairs millions of
173    /// times; precomputing turns each query into an array read instead of (for
174    /// [`HaversineMatrix`]) recomputing great-circle trigonometry. The `n²`
175    /// upfront cost and the dense storage only pay off below a size bound, so the
176    /// caller decides when to densify.
177    #[must_use]
178    pub fn densify<M: CostMatrix>(source: &M, n: usize) -> Self {
179        let mut distances = Vec::with_capacity(n * n);
180        let mut times = Vec::with_capacity(n * n);
181        for a in 0..n {
182            for b in 0..n {
183                distances.push(source.distance(LocationId(a), LocationId(b)));
184                times.push(source.time(LocationId(a), LocationId(b)));
185            }
186        }
187        Self {
188            n,
189            distances,
190            times,
191        }
192    }
193
194    /// Number of locations the matrix covers (`stops + 1`).
195    #[must_use]
196    pub fn len(&self) -> usize {
197        self.n
198    }
199
200    /// Whether the matrix is empty (never true for a value built by [`Self::new`]).
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.n == 0
204    }
205
206    /// Validate squareness and flatten to row-major, returning `(n, flat)`.
207    fn flatten(rows: Vec<Vec<f64>>) -> Result<(usize, Vec<f64>), MatrixError> {
208        let n = rows.len();
209        if n == 0 {
210            return Err(MatrixError::NotSquare {
211                rows: 0,
212                row_len: 0,
213            });
214        }
215        let mut flat = Vec::with_capacity(n * n);
216        for row in rows {
217            if row.len() != n {
218                return Err(MatrixError::NotSquare {
219                    rows: n,
220                    row_len: row.len(),
221                });
222            }
223            flat.extend(row);
224        }
225        Ok((n, flat))
226    }
227}
228
229impl CostMatrix for ProvidedMatrix {
230    fn distance(&self, a: LocationId, b: LocationId) -> f64 {
231        self.distances[a.0 * self.n + b.0]
232    }
233
234    fn time(&self, a: LocationId, b: LocationId) -> f64 {
235        self.times[a.0 * self.n + b.0]
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn matrix() -> HaversineMatrix {
244        // 0: equator origin, 1: 1° east, 2: 1° north
245        HaversineMatrix::new(
246            vec![
247                Coord::new(0.0, 0.0),
248                Coord::new(0.0, 1.0),
249                Coord::new(1.0, 0.0),
250            ],
251            60.0,
252        )
253    }
254
255    #[test]
256    fn distance_to_self_is_zero() {
257        let m = matrix();
258        assert_eq!(m.distance(LocationId(0), LocationId(0)), 0.0);
259    }
260
261    #[test]
262    fn distance_is_symmetric() {
263        let m = matrix();
264        for a in 0..3 {
265            for b in 0..3 {
266                let ab = m.distance(LocationId(a), LocationId(b));
267                let ba = m.distance(LocationId(b), LocationId(a));
268                assert!((ab - ba).abs() < 1e-9, "asymmetry {a}->{b}: {ab} vs {ba}");
269            }
270        }
271    }
272
273    #[test]
274    fn one_degree_is_about_111_km() {
275        let m = matrix();
276        // One degree of longitude at the equator ≈ 111.19 km.
277        let d = m.distance(LocationId(0), LocationId(1));
278        assert!((d - 111.19).abs() < 0.5, "expected ~111.19 km, got {d}");
279        // One degree of latitude is also ≈ 111.19 km.
280        let d_lat = m.distance(LocationId(0), LocationId(2));
281        assert!(
282            (d_lat - 111.19).abs() < 0.5,
283            "expected ~111.19 km, got {d_lat}"
284        );
285    }
286
287    #[test]
288    fn time_is_distance_over_speed() {
289        let m = matrix();
290        let d = m.distance(LocationId(0), LocationId(1));
291        let t = m.time(LocationId(0), LocationId(1));
292        assert!((t - d / 60.0).abs() < 1e-9);
293    }
294
295    #[test]
296    fn euclidean_is_pythagorean_and_time_equals_distance() {
297        // (0,0), (3,4) -> distance 5. Coord stores lon=x, lat=y.
298        let m = EuclideanMatrix::new(vec![Coord::new(0.0, 0.0), Coord::new(4.0, 3.0)]);
299        let d = m.distance(LocationId(0), LocationId(1));
300        assert!((d - 5.0).abs() < 1e-9, "expected 5.0, got {d}");
301        // Solomon convention: time == distance.
302        assert_eq!(m.time(LocationId(0), LocationId(1)), d);
303        assert_eq!(m.distance(LocationId(0), LocationId(0)), 0.0);
304    }
305
306    #[test]
307    fn provided_matrix_indexes_row_major() {
308        let m = ProvidedMatrix::new(
309            vec![
310                vec![0.0, 1.0, 2.0],
311                vec![3.0, 0.0, 4.0],
312                vec![5.0, 6.0, 0.0],
313            ],
314            vec![
315                vec![0.0, 10.0, 20.0],
316                vec![30.0, 0.0, 40.0],
317                vec![50.0, 60.0, 0.0],
318            ],
319        )
320        .expect("square");
321        assert_eq!(m.len(), 3);
322        assert_eq!(m.distance(LocationId(1), LocationId(2)), 4.0);
323        assert_eq!(m.distance(LocationId(2), LocationId(0)), 5.0);
324        assert_eq!(m.time(LocationId(0), LocationId(2)), 20.0);
325        // Asymmetry is preserved (a real road matrix need not be symmetric).
326        assert_ne!(
327            m.distance(LocationId(0), LocationId(1)),
328            m.distance(LocationId(1), LocationId(0))
329        );
330    }
331
332    #[test]
333    fn provided_matrix_rejects_non_square() {
334        let err = ProvidedMatrix::new(
335            vec![vec![0.0, 1.0], vec![1.0]],
336            vec![vec![0.0, 1.0], vec![1.0, 0.0]],
337        )
338        .unwrap_err();
339        assert_eq!(
340            err,
341            MatrixError::NotSquare {
342                rows: 2,
343                row_len: 1
344            }
345        );
346        assert_eq!(
347            ProvidedMatrix::new(vec![], vec![]).unwrap_err(),
348            MatrixError::NotSquare {
349                rows: 0,
350                row_len: 0
351            }
352        );
353    }
354
355    #[test]
356    fn densify_matches_source_exactly() {
357        // Densifying must be value-identical to the lazy source, or it would
358        // change solver results. Bit-exact, not approximate.
359        let h = HaversineMatrix::new(
360            vec![
361                Coord::new(48.8566, 2.3522),
362                Coord::new(48.8606, 2.3376),
363                Coord::new(48.8530, 2.3499),
364                Coord::new(48.8738, 2.2950),
365            ],
366            50.0,
367        );
368        let dense = ProvidedMatrix::densify(&h, 4);
369        assert_eq!(dense.len(), 4);
370        for a in 0..4 {
371            for b in 0..4 {
372                let (la, lb) = (LocationId(a), LocationId(b));
373                assert_eq!(dense.distance(la, lb), h.distance(la, lb));
374                assert_eq!(dense.time(la, lb), h.time(la, lb));
375            }
376        }
377    }
378
379    #[test]
380    fn provided_matrix_rejects_size_mismatch() {
381        let err =
382            ProvidedMatrix::new(vec![vec![0.0, 1.0], vec![1.0, 0.0]], vec![vec![0.0]]).unwrap_err();
383        assert_eq!(
384            err,
385            MatrixError::SizeMismatch {
386                distance: 2,
387                time: 1
388            }
389        );
390    }
391}