Skip to main content

hyperpaths_rs/
hyperpath.rs

1//! Implementation of the Spiess-Florian algorithm for transit assignment.
2//! See the ref. at spiess_floarian.tex LaTeX file.
3
4use std::collections::{HashMap, HashSet};
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use crate::hyperpath_queue::PriorityQueue;
8use crate::transit_network::Link;
9
10/// Strategy is the optimal strategy as defined in the Spiess-Florian algorithm.
11pub struct Strategy<'a> {
12    /// u_{i} - expected travel time from node i to destination
13    pub labels: HashMap<String, f64>,
14    /// f_{i} - combined frequency of attractive links at node i.
15    /// `f64::INFINITY` marks a node whose basket is a single no-wait link.
16    pub freqs: HashMap<String, f64>,
17    /// \overline{A} - attractive links forming the hyperpath
18    pub a_set: Vec<&'a Link>,
19}
20
21/// The waiting-time constant of the Spiess-Florian expected travel time:
22///   u_i = (1 + sum(f_a * (c_a + u_j))) / f_i
23/// When the first attractive link arrives at a node the sum is empty and
24/// the numerator starts from this constant.
25pub(crate) const ALPHA: f64 = 1.0;
26
27pub static VERBOSE: AtomicBool = AtomicBool::new(false);
28
29pub(crate) fn verbose() -> bool {
30    VERBOSE.load(Ordering::Relaxed)
31}
32
33pub fn find_optimal_strategy<'a>(
34    all_links: &'a [Link],
35    all_stops: &HashSet<String>,
36    destination: &str,
37) -> Strategy<'a> {
38    /* 1.1 Initialization */
39    if verbose() {
40        println!("1.1 Initialization \\\\");
41    }
42    let mut u: HashMap<String, f64> = HashMap::with_capacity(all_stops.len());
43    let mut f: HashMap<String, f64> = HashMap::with_capacity(all_stops.len());
44    for stop in all_stops {
45        if verbose() {
46            println!("$f_{{{}}} = 0$ \\\\ ", stop);
47        }
48        f.insert(stop.clone(), 0.0);
49        if stop == destination {
50            if verbose() {
51                println!("$u_{{{}}} = 0$ \\\\ ", destination);
52            }
53            u.insert(stop.clone(), 0.0);
54            continue;
55        }
56        if verbose() {
57            println!("$u_{{{}}} = Infinity$ \\\\ ", stop);
58        }
59        u.insert(stop.clone(), f64::INFINITY);
60    }
61
62    let mut overline_a: Vec<Option<&'a Link>> = Vec::with_capacity(all_links.len() / 2);
63    // Positions of each node's basket links inside overline_a, so that a
64    // no-wait link can replace the whole basket. Replaced entries are set
65    // to None and compacted at the end.
66    let mut a_set_idx: HashMap<&'a str, Vec<usize>> = HashMap::new();
67
68    let mut links_by_to_node: HashMap<&'a str, Vec<&'a Link>> = HashMap::new();
69    for link in all_links {
70        links_by_to_node
71            .entry(link.to_node.as_str())
72            .or_default()
73            .push(link);
74    }
75
76    let mut entries: HashMap<&'a str, Vec<usize>> = HashMap::with_capacity(all_links.len());
77    let mut pq = PriorityQueue::with_capacity(all_links.len());
78    for link in all_links {
79        let priority = u.get(&link.to_node).copied().unwrap_or(0.0) + link.travel_cost;
80        let id = pq.push(link, priority);
81        entries.entry(link.from_node.as_str()).or_default().push(id);
82    }
83    pq.init();
84    if verbose() {
85        pq.print();
86    }
87    while pq.len() > 0 {
88        /* 1.2 Get next link */
89        if verbose() {
90            pq.print();
91        }
92        let entry_id = match pq.pop() {
93            Some(id) => id,
94            None => break,
95        };
96        let priority = pq.priority(entry_id);
97        if priority.is_infinite() && priority > 0.0 {
98            break;
99        }
100        let a = pq.link(entry_id);
101        let i = a.from_node.as_str();
102        let j = a.to_node.as_str();
103        let sum_uc = u.get(j).copied().unwrap_or(0.0) + a.travel_cost;
104
105        /* 1.3 Update node label */
106        if verbose() {
107            println!("Process: $a = (i, j) = ({}, {})$, \\\\ ", i, j);
108        }
109        // A node already served by a no-wait link is final: the no-wait
110        // link absorbs all flow (its share f_a/f_i is 1 in the limit),
111        // so no other link may enter the basket
112        let f_i = f.get(i).copied().unwrap_or(0.0);
113        if f_i.is_infinite() {
114            continue;
115        }
116        let u_i = u.get(i).copied().unwrap_or(0.0);
117        // Strict improvement test: a link is accepted only if it
118        // strictly improves the label. Step 1.3 of Spiess & Florian
119        // (1989) prints the nonstrict u_i >= u_j + c_a, but the two
120        // rules differ only at exact equality, where the update is a
121        // no-op (the combination formula returns u_i unchanged; for
122        // f_a = inf the basket is replaced at the same value): labels,
123        // expected travel times and every number published in the
124        // paper are identical either way. The strict form is what
125        // part 2 needs. Step 2.2 loads links "in reverse topological
126        // order (decreasing u_j + c_a)" (p. 94) and Proposition 4
127        // claims flow conservation "by construction" - both presume an
128        // acyclic strategy, which the nonstrict rule does not
129        // guarantee: in an expanded route graph a boarding link (cost
130        // 0) into a route node whose label came from its own alighting
131        // link (cost 0) has key exactly u_i, so >= admits a zero-cost
132        // stop -> node -> stop cycle and the one-pass loading strands
133        // the volume entering it (see
134        // test_board_alight_loop_conservation). Rejecting at equality
135        // keeps the strategy acyclic and stays optimal: for the
136        // rejected link mu_a = 0 satisfies dual feasibility (20) as an
137        // equality and complementary slackness (24) holds since
138        // v_a = 0, a degenerate optimum. The prose of p. 94 ("if this
139        // time is smaller than u_i, link a is included") describes
140        // exactly this strict rule. All step, equation and page
141        // references above are to the original paper, not to the
142        // spiess_floarian.tex excerpt in this repository.
143        if u_i <= sum_uc {
144            continue;
145        }
146        if verbose() {
147            println!(
148                "\\quad $u_i \\leq u_j + c_a : {} \\leq {}$ - FALSE \\\\ ",
149                u_i, sum_uc
150            );
151        }
152        if a.headway <= 0.0 {
153            // No-wait link (infinite frequency): the modified step 1.3
154            // given by the paper on p. 96 - the exact limit of the label
155            // update formula as f_a -> inf. The link replaces the whole
156            // attractive basket:
157            //   u_i := u_j + c_a, f_i := inf, A_i := {a}
158            u.insert(i.to_string(), sum_uc);
159            f.insert(i.to_string(), f64::INFINITY);
160            let indices = a_set_idx.entry(i).or_default();
161            for idx in indices.iter() {
162                overline_a[*idx] = None;
163            }
164            indices.clear();
165            overline_a.push(Some(a));
166            indices.push(overline_a.len() - 1);
167            if verbose() {
168                println!(
169                    "\\quad no-wait link: $u_i = u_j + c_a = {}$, $f_i = \\infty$, basket replaced by $({}, {})$ \\\\ ",
170                    sum_uc, i, j
171                );
172            }
173        } else {
174            let freq = 1.0 / a.headway;
175            if verbose() {
176                println!("\\quad $f_a = {}$ \\\\ ", freq);
177                println!("\\quad $u_j + c_a = {}$ \\\\ ", sum_uc);
178                println!("\\quad $u_i = {}$ \\\\ ", u_i);
179            }
180            let new_u = if f_i == 0.0 {
181                // First link in the basket: u_i = (1 + f_a*(u_j+c_a)) / f_a
182                (ALPHA + freq * sum_uc) / freq
183            } else {
184                (f_i * u_i + freq * sum_uc) / (f_i + freq)
185            };
186            u.insert(i.to_string(), new_u);
187            f.insert(i.to_string(), f_i + freq);
188            overline_a.push(Some(a));
189            a_set_idx.entry(i).or_default().push(overline_a.len() - 1);
190            if verbose() {
191                println!(
192                    "\\quad$u_i = \\frac{{f_i * u_i + f_a * (u_j + c_a)}}{{f_i + f_a}} = {}$, $f_i = {}$ \\\\ ",
193                    new_u,
194                    f_i + freq
195                );
196                println!(
197                    "\\quad $\\overline{{A}} = \\overline{{A}} \\cup {{({}, {})}}$ \\\\ ",
198                    i, j
199                );
200            }
201        }
202
203        if let Some(links_to_update) = links_by_to_node.get(i) {
204            for link in links_to_update {
205                if let Some(i_entries) = entries.get(link.from_node.as_str()) {
206                    for &eid in i_entries {
207                        let entry_link = pq.link(eid);
208                        if entry_link.to_node == i && entry_link.from_node == link.from_node {
209                            let new_priority = u.get(i).copied().unwrap_or(0.0) + link.travel_cost;
210                            pq.update(eid, new_priority);
211                            break;
212                        }
213                    }
214                }
215            }
216        }
217        if verbose() {
218            println!("Node labels: \\\\");
219            for s in all_stops {
220                println!(
221                    "${} -> (u_i, f_i) = ({}, {})$ \\\\ ",
222                    s,
223                    u.get(s).copied().unwrap_or(0.0),
224                    f.get(s).copied().unwrap_or(0.0)
225                );
226            }
227        }
228    }
229
230    // Compact the attractive set: drop entries replaced by no-wait links.
231    // The append order is preserved, i.e. non-decreasing u_j + c_a.
232    let a_set: Vec<&'a Link> = overline_a.into_iter().flatten().collect();
233
234    Strategy {
235        labels: u,
236        freqs: f,
237        a_set,
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_hyper_paths() {
247        VERBOSE.store(true, Ordering::Relaxed);
248        let all_nodes: HashSet<String> = ["A", "X", "X2", "Y", "Y3", "B"]
249            .iter()
250            .map(|s| s.to_string())
251            .collect();
252        let all_links = vec![
253            Link::new("A", "B", "Line 1", 25.0, 6.0),
254            Link::new("A", "X2", "Line 2", 7.0, 6.0),
255            Link::new("X2", "X", "Line 2", 0.0, 0.0),
256            Link::new("X", "X2", "Line 2", 0.0, 6.0),
257            Link::new("X2", "Y", "Line 2", 6.0, 0.0),
258            Link::new("Y3", "Y", "Line 3", 0.0, 15.0),
259            Link::new("Y", "B", "Line 4", 10.0, 3.0),
260            Link::new("X", "Y3", "Line 3", 4.0, 15.0),
261            Link::new("Y", "Y3", "Line 3", 0.0, 15.0),
262            Link::new("Y3", "B", "Line 3", 4.0, 0.0),
263        ];
264        let destination_node = "B";
265        let ops = find_optimal_strategy(&all_links, &all_nodes, destination_node);
266
267        const EPS: f64 = 1e-9;
268
269        // With exact no-wait handling the labels match the paper exactly:
270        // no big-M artifacts like 4.000000000000001
271        let expected_labels: HashMap<&str, f64> = HashMap::from([
272            ("A", 27.75),
273            ("X", 19.071428571428573),
274            ("X2", 17.5),
275            ("Y", 11.5),
276            ("Y3", 4.0),
277            ("B", 0.0),
278        ]);
279        // +Inf marks nodes whose basket is a single no-wait link
280        let expected_freqs: HashMap<&str, f64> = HashMap::from([
281            ("A", 1.0 / 3.0),
282            ("X", 7.0 / 30.0),
283            ("X2", f64::INFINITY),
284            ("Y", 0.4),
285            ("Y3", f64::INFINITY),
286            ("B", 0.0),
287        ]);
288        // Matches the paper order (Spiess & Florian 1989, p. 93-94)
289        let expected_a_set: Vec<&Link> = vec![
290            // Y3->B
291            &all_links[9],
292            // Y->Y3
293            &all_links[8],
294            // X->Y3
295            &all_links[7],
296            // Y->B
297            &all_links[6],
298            // X2->Y
299            &all_links[4],
300            // X->X2
301            &all_links[3],
302            // A->X2
303            &all_links[1],
304            // A->B
305            &all_links[0],
306        ];
307
308        assert_eq!(
309            ops.labels.len(),
310            expected_labels.len(),
311            "Incorrect number of labels"
312        );
313        assert_eq!(
314            ops.freqs.len(),
315            expected_freqs.len(),
316            "Incorrect number of frequencies"
317        );
318        assert_eq!(
319            ops.a_set.len(),
320            expected_a_set.len(),
321            "Incorrect number of links in attractive set"
322        );
323
324        for (k, v) in &ops.labels {
325            assert!(
326                expected_labels.contains_key(k.as_str()),
327                "Incorrect label key {} has met",
328                k
329            );
330            let want = expected_labels[k.as_str()];
331            assert!(
332                (v - want).abs() <= EPS,
333                "Incorrect label value for node {}: got {}, want {}",
334                k,
335                v,
336                want
337            );
338        }
339        for (k, v) in &ops.freqs {
340            assert!(
341                expected_freqs.contains_key(k.as_str()),
342                "Incorrect frequency key {} has met",
343                k
344            );
345            let want = expected_freqs[k.as_str()];
346            if want.is_infinite() {
347                assert!(
348                    v.is_infinite() && *v > 0.0,
349                    "Frequency for node {} must be +Inf, got {}",
350                    k,
351                    v
352                );
353            } else {
354                assert!(
355                    (v - want).abs() <= EPS,
356                    "Incorrect frequency value for node {}: got {}, want {}",
357                    k,
358                    v,
359                    want
360                );
361            }
362        }
363        for (i, v) in ops.a_set.iter().enumerate() {
364            println!("{:?} {:?}", v, expected_a_set[i]);
365            assert!(
366                std::ptr::eq(*v, expected_a_set[i]),
367                "Incorrect link in attractive set at index {}",
368                i
369            );
370        }
371    }
372
373    #[test]
374    fn test_no_wait_replaces_basket() {
375        // A boarding link enters the basket of I first (key 4), then a
376        // cheaper no-wait chain I->W->D (key 5 < current u_I = 10) must
377        // replace it entirely: exact label, infinite frequency, single link.
378        let all_nodes: HashSet<String> = ["I", "W", "D"].iter().map(|s| s.to_string()).collect();
379        let all_links = vec![
380            // boarding link, key u_D + 4 = 4, accepted first: u_I = 6 + 4 = 10
381            Link::new("I", "D", "Bus", 4.0, 6.0),
382            // no-wait walk, key u_W + 3 = 5, replaces the basket: u_I = 5
383            Link::new("I", "W", "Walk", 3.0, 0.0),
384            // no-wait walk, key 2
385            Link::new("W", "D", "Walk", 2.0, 0.0),
386        ];
387        let ops = find_optimal_strategy(&all_links, &all_nodes, "D");
388
389        assert!((ops.labels["I"] - 5.0).abs() <= 1e-12);
390        assert!((ops.labels["W"] - 2.0).abs() <= 1e-12);
391        assert!(ops.freqs["I"].is_infinite());
392        assert!(ops.freqs["W"].is_infinite());
393
394        // The replaced boarding link I->D must not remain attractive
395        assert_eq!(
396            ops.a_set.len(),
397            2,
398            "basket of I must hold only the no-wait link"
399        );
400        for link in &ops.a_set {
401            assert_eq!(
402                link.headway, 0.0,
403                "only no-wait links expected in the attractive set"
404            );
405        }
406    }
407}