Skip to main content

osmnxr_core/
metrics.rs

1//! Urban / network metrics computed on the Rust side.
2
3use std::f64::consts::PI;
4
5/// Summary statistics for a set of weighted edges.
6pub struct BasicStats {
7    pub n_nodes: usize,
8    pub n_edges: usize,
9    pub total_length: f64,
10    pub mean_length: f64,
11    pub mean_out_degree: f64,
12    pub self_loops: usize,
13}
14
15/// Compute basic network statistics from edge endpoints and weights.
16pub fn basic_stats(from: &[usize], to: &[usize], weight: &[f64], n_nodes: usize) -> BasicStats {
17    let n_edges = from.len();
18    let total_length: f64 = weight.iter().sum();
19    let mean_length = if n_edges > 0 {
20        total_length / n_edges as f64
21    } else {
22        0.0
23    };
24    let mean_out_degree = if n_nodes > 0 {
25        n_edges as f64 / n_nodes as f64
26    } else {
27        0.0
28    };
29    let self_loops = from.iter().zip(to).filter(|(u, v)| u == v).count();
30    BasicStats {
31        n_nodes,
32        n_edges,
33        total_length,
34        mean_length,
35        mean_out_degree,
36        self_loops,
37    }
38}
39
40/// Shannon entropy (in nats) of edge compass bearings, binned into `num_bins`
41/// equal sectors over `[0, 360)`. Higher entropy = more disordered (organic)
42/// street grid; lower = more ordered (gridiron). Bearings are in degrees.
43pub fn orientation_entropy(bearings: &[f64], num_bins: usize) -> f64 {
44    if bearings.is_empty() || num_bins == 0 {
45        return 0.0;
46    }
47    let mut counts = vec![0.0f64; num_bins];
48    let bin_width = 360.0 / num_bins as f64;
49    for &b in bearings {
50        let bb = b.rem_euclid(360.0);
51        let mut idx = (bb / bin_width).floor() as usize;
52        if idx >= num_bins {
53            idx = num_bins - 1;
54        }
55        counts[idx] += 1.0;
56    }
57    let total: f64 = counts.iter().sum();
58    let mut h = 0.0;
59    for c in counts {
60        if c > 0.0 {
61            let p = c / total;
62            h -= p * p.ln();
63        }
64    }
65    h
66}
67
68/// Initial compass bearing (degrees, clockwise from north) from point
69/// `(lat1, lon1)` to `(lat2, lon2)`, both in decimal degrees.
70pub fn bearing(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
71    let (phi1, phi2) = (lat1.to_radians(), lat2.to_radians());
72    let dlon = (lon2 - lon1).to_radians();
73    let y = dlon.sin() * phi2.cos();
74    let x = phi1.cos() * phi2.sin() - phi1.sin() * phi2.cos() * dlon.cos();
75    let theta = y.atan2(x);
76    (theta * 180.0 / PI).rem_euclid(360.0)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn perfect_grid_has_low_entropy() {
85        // Two orientations only (E-W and N-S): low entropy.
86        let grid = vec![0.0, 90.0, 180.0, 270.0, 0.0, 90.0, 180.0, 270.0];
87        let uniform = vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0];
88        let h_grid = orientation_entropy(&grid, 8);
89        let h_uniform = orientation_entropy(&uniform, 8);
90        assert!(h_grid < h_uniform);
91    }
92
93    #[test]
94    fn bearing_due_east_is_90() {
95        let b = bearing(0.0, 0.0, 0.0, 1.0);
96        assert!((b - 90.0).abs() < 1e-6);
97    }
98}