Skip to main content

oxigdal_algorithms/vector/network/
service_area.rs

1//! Service area and isochrone calculation
2//!
3//! Compute reachable areas from one or more locations within specified constraints:
4//!
5//! - **Service areas**: Determine all reachable nodes within cost thresholds
6//! - **Isochrones**: Generate contour polygons of equal travel time/distance
7//! - **Multi-facility**: Compute service areas from multiple origins simultaneously
8//! - **Overlapping analysis**: Identify competition zones between facilities
9//! - **Break values**: Support multiple cost thresholds for tiered analysis
10
11use crate::error::{AlgorithmError, Result};
12use crate::vector::network::{EdgeId, Graph, NodeId, ShortestPathOptions};
13use oxigdal_core::vector::{Coordinate, LineString, Polygon};
14use std::collections::{BinaryHeap, HashMap, HashSet};
15
16/// Options for service area calculation
17#[derive(Debug, Clone)]
18pub struct ServiceAreaOptions {
19    /// Maximum cost (distance or time)
20    pub max_cost: f64,
21    /// Cost intervals for multiple service areas (break values)
22    pub intervals: Vec<f64>,
23    /// Whether to include unreachable nodes
24    pub include_unreachable: bool,
25    /// Cost type to use
26    pub cost_type: ServiceAreaCostType,
27    /// Weight criteria for multi-criteria cost
28    pub weight_criteria: Option<HashMap<String, f64>>,
29}
30
31/// What cost metric to use for service area computation
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ServiceAreaCostType {
34    /// Use edge weight (distance)
35    Distance,
36    /// Use travel time
37    Time,
38    /// Use multi-criteria weighted cost
39    MultiCriteria,
40}
41
42impl Default for ServiceAreaOptions {
43    fn default() -> Self {
44        Self {
45            max_cost: 1000.0,
46            intervals: vec![250.0, 500.0, 750.0, 1000.0],
47            include_unreachable: false,
48            cost_type: ServiceAreaCostType::Distance,
49            weight_criteria: None,
50        }
51    }
52}
53
54/// A service area result
55#[derive(Debug, Clone)]
56pub struct ServiceArea {
57    /// Origin node
58    pub origin: NodeId,
59    /// Reachable nodes with their costs
60    pub reachable_nodes: HashMap<NodeId, f64>,
61    /// Service area intervals (break values)
62    pub intervals: Vec<ServiceAreaInterval>,
63    /// Edges reachable within the max cost
64    pub reachable_edges: Vec<(EdgeId, f64, f64)>, // (edge, start_cost, end_cost)
65}
66
67/// A single service area interval (break value)
68#[derive(Debug, Clone)]
69pub struct ServiceAreaInterval {
70    /// Maximum cost for this interval
71    pub max_cost: f64,
72    /// Nodes within this interval
73    pub nodes: Vec<NodeId>,
74    /// Number of reachable nodes
75    pub count: usize,
76    /// Boundary polygon (if computed)
77    pub boundary: Option<Polygon>,
78    /// Area of the boundary polygon (if computed)
79    pub area: Option<f64>,
80}
81
82/// Options for isochrone calculation
83#[derive(Debug, Clone)]
84pub struct IsochroneOptions {
85    /// Time intervals in seconds
86    pub time_intervals: Vec<f64>,
87    /// Whether to smooth the isochrone polygons
88    pub smooth: bool,
89    /// Smoothing factor (0.0 - 1.0)
90    pub smoothing_factor: f64,
91    /// Method for polygon generation
92    pub polygon_method: IsochronePolygonMethod,
93}
94
95/// Method for generating isochrone polygons
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum IsochronePolygonMethod {
98    /// Convex hull (fast, less accurate)
99    ConvexHull,
100    /// Concave hull / alpha shape (slower, more accurate)
101    ConcaveHull,
102}
103
104impl Default for IsochroneOptions {
105    fn default() -> Self {
106        Self {
107            time_intervals: vec![300.0, 600.0, 900.0, 1200.0], // 5, 10, 15, 20 minutes
108            smooth: true,
109            smoothing_factor: 0.5,
110            polygon_method: IsochronePolygonMethod::ConvexHull,
111        }
112    }
113}
114
115/// An isochrone (contour of equal travel time/distance)
116#[derive(Debug, Clone)]
117pub struct Isochrone {
118    /// Time/distance value for this isochrone
119    pub value: f64,
120    /// Polygon representing the isochrone boundary
121    pub polygon: Option<Polygon>,
122    /// Nodes on or inside the isochrone
123    pub nodes: Vec<NodeId>,
124    /// Area of the isochrone polygon (if computed)
125    pub area: Option<f64>,
126    /// Perimeter of the isochrone polygon (if computed)
127    pub perimeter: Option<f64>,
128}
129
130/// Multi-facility service area result
131#[derive(Debug, Clone)]
132pub struct MultiFacilityResult {
133    /// Individual service areas per facility
134    pub facility_areas: Vec<ServiceArea>,
135    /// Assignment of each node to its nearest facility
136    pub nearest_facility: HashMap<NodeId, (NodeId, f64)>, // node -> (facility, cost)
137    /// Overlap zones between facilities
138    pub overlap_zones: Vec<OverlapZone>,
139    /// Nodes not reachable from any facility
140    pub unreachable_nodes: Vec<NodeId>,
141}
142
143/// An overlap zone between two or more facilities
144#[derive(Debug, Clone)]
145pub struct OverlapZone {
146    /// Facility IDs involved in this overlap
147    pub facilities: Vec<NodeId>,
148    /// Nodes in the overlap zone
149    pub nodes: Vec<NodeId>,
150    /// Boundary polygon of the overlap zone (if computed)
151    pub boundary: Option<Polygon>,
152}
153
154/// Calculate service area from a starting node
155///
156/// # Arguments
157///
158/// * `graph` - The network graph
159/// * `origin` - Starting node
160/// * `options` - Service area options
161///
162/// # Returns
163///
164/// Service area showing all reachable nodes and areas
165pub fn calculate_service_area(
166    graph: &Graph,
167    origin: NodeId,
168    options: &ServiceAreaOptions,
169) -> Result<ServiceArea> {
170    if graph.get_node(origin).is_none() {
171        return Err(AlgorithmError::InvalidGeometry(format!(
172            "Origin node {:?} not found",
173            origin
174        )));
175    }
176
177    // Use Dijkstra-like expansion to find all reachable nodes
178    let mut distances: HashMap<NodeId, f64> = HashMap::new();
179    let mut heap = BinaryHeap::new();
180    let mut reachable_edges: Vec<(EdgeId, f64, f64)> = Vec::new();
181
182    distances.insert(origin, 0.0);
183    heap.push(ServiceAreaState {
184        cost: 0.0,
185        node: origin,
186    });
187
188    while let Some(ServiceAreaState { cost, node }) = heap.pop() {
189        // Skip if we've exceeded max cost
190        if cost > options.max_cost {
191            continue;
192        }
193
194        // Skip if we've already found a better path
195        if let Some(&best_cost) = distances.get(&node) {
196            if cost > best_cost {
197                continue;
198            }
199        }
200
201        // Explore neighbors
202        for &edge_id in graph.outgoing_edges(node) {
203            if let Some(edge) = graph.get_edge(edge_id) {
204                let next_node = edge.target;
205                let edge_cost = compute_edge_cost(edge, options);
206                let next_cost = cost + edge_cost;
207
208                if next_cost <= options.max_cost {
209                    let is_better = distances
210                        .get(&next_node)
211                        .is_none_or(|&current| next_cost < current);
212
213                    if is_better {
214                        distances.insert(next_node, next_cost);
215                        reachable_edges.push((edge_id, cost, next_cost));
216                        heap.push(ServiceAreaState {
217                            cost: next_cost,
218                            node: next_node,
219                        });
220                    }
221                }
222            }
223        }
224    }
225
226    // Build service area intervals with break values
227    let mut intervals = Vec::new();
228
229    for &interval_cost in &options.intervals {
230        let nodes: Vec<NodeId> = distances
231            .iter()
232            .filter(|(_, cost)| **cost <= interval_cost)
233            .map(|(node, _)| *node)
234            .collect();
235
236        // Build boundary polygon
237        let boundary = build_isochrone_polygon(graph, &nodes, 0.5).ok().flatten();
238        let area = boundary.as_ref().map(|p| compute_polygon_area(p));
239
240        intervals.push(ServiceAreaInterval {
241            max_cost: interval_cost,
242            count: nodes.len(),
243            nodes,
244            boundary,
245            area,
246        });
247    }
248
249    Ok(ServiceArea {
250        origin,
251        reachable_nodes: distances,
252        intervals,
253        reachable_edges,
254    })
255}
256
257/// Compute the cost of an edge based on service area options
258fn compute_edge_cost(edge: &crate::vector::network::Edge, options: &ServiceAreaOptions) -> f64 {
259    match options.cost_type {
260        ServiceAreaCostType::Distance => edge.weight,
261        ServiceAreaCostType::Time => {
262            edge.travel_time()
263                .map(|t| t * 3600.0) // hours to seconds
264                .unwrap_or(edge.weight / 13.89) // default ~50 km/h
265        }
266        ServiceAreaCostType::MultiCriteria => {
267            if let Some(ref criteria) = options.weight_criteria {
268                edge.multi_weight.weighted_cost(criteria)
269            } else {
270                edge.weight
271            }
272        }
273    }
274}
275
276/// Calculate multi-facility service areas
277///
278/// Computes service areas from multiple facilities simultaneously,
279/// assigning each node to the nearest facility and identifying overlap zones.
280pub fn calculate_multi_facility_service_area(
281    graph: &Graph,
282    facilities: &[NodeId],
283    options: &ServiceAreaOptions,
284) -> Result<MultiFacilityResult> {
285    if facilities.is_empty() {
286        return Err(AlgorithmError::InvalidInput(
287            "At least one facility is required".to_string(),
288        ));
289    }
290
291    // Validate all facilities exist
292    for &facility in facilities {
293        if graph.get_node(facility).is_none() {
294            return Err(AlgorithmError::InvalidGeometry(format!(
295                "Facility node {:?} not found",
296                facility
297            )));
298        }
299    }
300
301    // Multi-source Dijkstra: expand from all facilities simultaneously
302    let mut distances: HashMap<NodeId, f64> = HashMap::new();
303    let mut nearest_facility: HashMap<NodeId, (NodeId, f64)> = HashMap::new();
304    let mut heap = BinaryHeap::new();
305
306    // Initialize with all facilities at cost 0
307    for &facility in facilities {
308        distances.insert(facility, 0.0);
309        nearest_facility.insert(facility, (facility, 0.0));
310        heap.push(MultiFacilityState {
311            cost: 0.0,
312            node: facility,
313            facility,
314        });
315    }
316
317    // Track all facility costs per node (for overlap analysis)
318    let mut all_facility_costs: HashMap<NodeId, Vec<(NodeId, f64)>> = HashMap::new();
319
320    while let Some(MultiFacilityState {
321        cost,
322        node,
323        facility,
324    }) = heap.pop()
325    {
326        if cost > options.max_cost {
327            continue;
328        }
329
330        // Check if this is better than current best for this (node, facility) pair
331        let entry = all_facility_costs.entry(node).or_default();
332        let already_visited_by_facility = entry.iter().any(|(f, c)| *f == facility && *c <= cost);
333        if already_visited_by_facility {
334            continue;
335        }
336        entry.push((facility, cost));
337
338        // Update nearest facility
339        let update_nearest = nearest_facility
340            .get(&node)
341            .is_none_or(|&(_, current_cost)| cost < current_cost);
342
343        if update_nearest {
344            distances.insert(node, cost);
345            nearest_facility.insert(node, (facility, cost));
346        }
347
348        // Expand
349        for &edge_id in graph.outgoing_edges(node) {
350            if let Some(edge) = graph.get_edge(edge_id) {
351                let next_node = edge.target;
352                let edge_cost = compute_edge_cost(edge, options);
353                let next_cost = cost + edge_cost;
354
355                if next_cost <= options.max_cost {
356                    heap.push(MultiFacilityState {
357                        cost: next_cost,
358                        node: next_node,
359                        facility,
360                    });
361                }
362            }
363        }
364    }
365
366    // Compute individual facility areas
367    let mut facility_areas = Vec::new();
368    for &facility in facilities {
369        let reachable: HashMap<NodeId, f64> = all_facility_costs
370            .iter()
371            .filter_map(|(&node, costs)| {
372                costs
373                    .iter()
374                    .find(|(f, _)| *f == facility)
375                    .map(|(_, c)| (node, *c))
376            })
377            .collect();
378
379        let mut intervals = Vec::new();
380        for &interval_cost in &options.intervals {
381            let nodes: Vec<NodeId> = reachable
382                .iter()
383                .filter(|(_, cost)| **cost <= interval_cost)
384                .map(|(node, _)| *node)
385                .collect();
386
387            intervals.push(ServiceAreaInterval {
388                max_cost: interval_cost,
389                count: nodes.len(),
390                nodes,
391                boundary: None,
392                area: None,
393            });
394        }
395
396        facility_areas.push(ServiceArea {
397            origin: facility,
398            reachable_nodes: reachable,
399            intervals,
400            reachable_edges: Vec::new(),
401        });
402    }
403
404    // Identify overlap zones
405    let overlap_zones = compute_overlap_zones(&all_facility_costs, facilities, options);
406
407    // Identify unreachable nodes
408    let all_nodes: HashSet<NodeId> = graph.node_ids().into_iter().collect();
409    let reachable_nodes: HashSet<NodeId> = distances.keys().copied().collect();
410    let unreachable_nodes: Vec<NodeId> = all_nodes.difference(&reachable_nodes).copied().collect();
411
412    Ok(MultiFacilityResult {
413        facility_areas,
414        nearest_facility,
415        overlap_zones,
416        unreachable_nodes,
417    })
418}
419
420/// Compute overlap zones between facilities
421fn compute_overlap_zones(
422    all_facility_costs: &HashMap<NodeId, Vec<(NodeId, f64)>>,
423    facilities: &[NodeId],
424    _options: &ServiceAreaOptions,
425) -> Vec<OverlapZone> {
426    // Group nodes by which facilities can reach them
427    let mut facility_sets: HashMap<Vec<NodeId>, Vec<NodeId>> = HashMap::new();
428
429    for (&node, costs) in all_facility_costs {
430        let mut reaching_facilities: Vec<NodeId> = costs.iter().map(|(f, _)| *f).collect();
431        reaching_facilities.sort();
432        reaching_facilities.dedup();
433
434        if reaching_facilities.len() >= 2 {
435            facility_sets
436                .entry(reaching_facilities)
437                .or_default()
438                .push(node);
439        }
440    }
441
442    // Build overlap zones
443    let mut zones = Vec::new();
444    for (fac_set, nodes) in facility_sets {
445        if nodes.is_empty() {
446            continue;
447        }
448        zones.push(OverlapZone {
449            facilities: fac_set,
450            nodes,
451            boundary: None,
452        });
453    }
454
455    zones
456}
457
458/// State for service area computation
459#[derive(Debug, Clone)]
460struct ServiceAreaState {
461    cost: f64,
462    node: NodeId,
463}
464
465impl PartialEq for ServiceAreaState {
466    fn eq(&self, other: &Self) -> bool {
467        self.cost == other.cost
468    }
469}
470
471impl Eq for ServiceAreaState {}
472
473impl PartialOrd for ServiceAreaState {
474    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
475        Some(self.cmp(other))
476    }
477}
478
479impl Ord for ServiceAreaState {
480    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
481        // Reverse ordering for min-heap
482        other
483            .cost
484            .partial_cmp(&self.cost)
485            .unwrap_or(std::cmp::Ordering::Equal)
486    }
487}
488
489/// State for multi-facility service area
490#[derive(Debug, Clone)]
491struct MultiFacilityState {
492    cost: f64,
493    node: NodeId,
494    facility: NodeId,
495}
496
497impl PartialEq for MultiFacilityState {
498    fn eq(&self, other: &Self) -> bool {
499        self.cost == other.cost
500    }
501}
502
503impl Eq for MultiFacilityState {}
504
505impl PartialOrd for MultiFacilityState {
506    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
507        Some(self.cmp(other))
508    }
509}
510
511impl Ord for MultiFacilityState {
512    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
513        other
514            .cost
515            .partial_cmp(&self.cost)
516            .unwrap_or(std::cmp::Ordering::Equal)
517    }
518}
519
520/// Calculate isochrones from a starting point
521///
522/// # Arguments
523///
524/// * `graph` - The network graph
525/// * `origin` - Starting node
526/// * `options` - Isochrone options
527///
528/// # Returns
529///
530/// Vector of isochrones for each time interval
531pub fn calculate_isochrones(
532    graph: &Graph,
533    origin: NodeId,
534    options: &IsochroneOptions,
535) -> Result<Vec<Isochrone>> {
536    // Find the maximum interval safely
537    let max_interval = options
538        .time_intervals
539        .iter()
540        .copied()
541        .fold(f64::NEG_INFINITY, f64::max);
542
543    let max_cost = if max_interval.is_finite() {
544        max_interval
545    } else {
546        1000.0
547    };
548
549    // Calculate service area
550    let service_area_options = ServiceAreaOptions {
551        max_cost,
552        intervals: options.time_intervals.clone(),
553        include_unreachable: false,
554        cost_type: ServiceAreaCostType::Distance,
555        weight_criteria: None,
556    };
557
558    let service_area = calculate_service_area(graph, origin, &service_area_options)?;
559
560    // Build isochrones from service area intervals
561    let mut isochrones = Vec::new();
562
563    for interval in service_area.intervals {
564        let polygon = if options.smooth {
565            match options.polygon_method {
566                IsochronePolygonMethod::ConvexHull => {
567                    build_isochrone_polygon(graph, &interval.nodes, options.smoothing_factor)?
568                }
569                IsochronePolygonMethod::ConcaveHull => {
570                    build_concave_hull_polygon(graph, &interval.nodes, options.smoothing_factor)?
571                }
572            }
573        } else {
574            build_isochrone_polygon(graph, &interval.nodes, 0.0)?
575        };
576
577        let area = polygon.as_ref().map(|p| compute_polygon_area(p));
578        let perimeter = polygon.as_ref().map(|p| compute_polygon_perimeter(p));
579
580        isochrones.push(Isochrone {
581            value: interval.max_cost,
582            polygon,
583            nodes: interval.nodes,
584            area,
585            perimeter,
586        });
587    }
588
589    Ok(isochrones)
590}
591
592/// Calculate isochrones from multiple origins (multi-facility isochrones)
593pub fn calculate_multi_isochrones(
594    graph: &Graph,
595    origins: &[NodeId],
596    options: &IsochroneOptions,
597) -> Result<Vec<Vec<Isochrone>>> {
598    let mut all_isochrones = Vec::new();
599
600    for &origin in origins {
601        let isochrones = calculate_isochrones(graph, origin, options)?;
602        all_isochrones.push(isochrones);
603    }
604
605    Ok(all_isochrones)
606}
607
608/// Calculate drive-time polygons (isochrones using travel time)
609pub fn calculate_drive_time_polygons(
610    graph: &Graph,
611    origin: NodeId,
612    time_breaks: &[f64], // time intervals in seconds
613    speed_kmh: f64,
614) -> Result<Vec<Isochrone>> {
615    if graph.get_node(origin).is_none() {
616        return Err(AlgorithmError::InvalidGeometry(format!(
617            "Origin node {:?} not found",
618            origin
619        )));
620    }
621
622    let max_time = time_breaks.iter().copied().fold(0.0f64, f64::max);
623
624    let speed_ms = speed_kmh / 3.6; // Convert to m/s
625
626    // Expand using travel time
627    let mut arrival_times: HashMap<NodeId, f64> = HashMap::new();
628    let mut heap = BinaryHeap::new();
629
630    arrival_times.insert(origin, 0.0);
631    heap.push(ServiceAreaState {
632        cost: 0.0,
633        node: origin,
634    });
635
636    while let Some(ServiceAreaState { cost, node }) = heap.pop() {
637        if cost > max_time {
638            continue;
639        }
640
641        if let Some(&best) = arrival_times.get(&node) {
642            if cost > best {
643                continue;
644            }
645        }
646
647        for &edge_id in graph.outgoing_edges(node) {
648            if let Some(edge) = graph.get_edge(edge_id) {
649                let next_node = edge.target;
650
651                // Use edge speed limit or default
652                let edge_speed = edge.speed_limit.map(|s| s / 3.6).unwrap_or(speed_ms);
653
654                // Calculate travel time for this edge
655                let edge_distance = edge.weight; // Assuming weight is distance
656                let travel_time = if edge_speed > 0.0 {
657                    edge_distance / edge_speed
658                } else {
659                    edge_distance / speed_ms
660                };
661
662                let next_time = cost + travel_time;
663
664                if next_time <= max_time {
665                    let is_better = arrival_times
666                        .get(&next_node)
667                        .is_none_or(|&current| next_time < current);
668
669                    if is_better {
670                        arrival_times.insert(next_node, next_time);
671                        heap.push(ServiceAreaState {
672                            cost: next_time,
673                            node: next_node,
674                        });
675                    }
676                }
677            }
678        }
679    }
680
681    // Build isochrones for each time break
682    let mut isochrones = Vec::new();
683
684    for &time_break in time_breaks {
685        let nodes: Vec<NodeId> = arrival_times
686            .iter()
687            .filter(|(_, t)| **t <= time_break)
688            .map(|(n, _)| *n)
689            .collect();
690
691        let polygon = build_isochrone_polygon(graph, &nodes, 0.5)?;
692        let area = polygon.as_ref().map(|p| compute_polygon_area(p));
693        let perimeter = polygon.as_ref().map(|p| compute_polygon_perimeter(p));
694
695        isochrones.push(Isochrone {
696            value: time_break,
697            polygon,
698            nodes,
699            area,
700            perimeter,
701        });
702    }
703
704    Ok(isochrones)
705}
706
707/// Build a polygon from isochrone nodes using convex hull
708fn build_isochrone_polygon(
709    graph: &Graph,
710    nodes: &[NodeId],
711    _smoothing_factor: f64,
712) -> Result<Option<Polygon>> {
713    if nodes.len() < 3 {
714        return Ok(None);
715    }
716
717    // Get coordinates of all nodes
718    let mut coords: Vec<Coordinate> = nodes
719        .iter()
720        .filter_map(|&node_id| graph.get_node(node_id).map(|n| n.coordinate))
721        .collect();
722
723    if coords.len() < 3 {
724        return Ok(None);
725    }
726
727    // Compute convex hull as a simple polygon
728    coords = convex_hull(&coords);
729
730    if coords.len() < 3 {
731        return Ok(None);
732    }
733
734    // Close the ring
735    if let Some(first) = coords.first().copied() {
736        coords.push(first);
737    }
738
739    // Create polygon
740    let exterior = LineString::new(coords)
741        .map_err(|e| AlgorithmError::InvalidGeometry(format!("Invalid linestring: {}", e)))?;
742
743    let polygon = Polygon::new(exterior, vec![])
744        .map_err(|e| AlgorithmError::InvalidGeometry(format!("Invalid polygon: {}", e)))?;
745
746    Ok(Some(polygon))
747}
748
749/// Build a concave hull polygon (alpha shape approximation)
750fn build_concave_hull_polygon(
751    graph: &Graph,
752    nodes: &[NodeId],
753    alpha: f64,
754) -> Result<Option<Polygon>> {
755    if nodes.len() < 3 {
756        return Ok(None);
757    }
758
759    let coords: Vec<Coordinate> = nodes
760        .iter()
761        .filter_map(|&node_id| graph.get_node(node_id).map(|n| n.coordinate))
762        .collect();
763
764    if coords.len() < 3 {
765        return Ok(None);
766    }
767
768    // For small point sets, fall back to convex hull
769    if coords.len() < 10 {
770        return build_isochrone_polygon(graph, nodes, alpha);
771    }
772
773    // Alpha shape approximation:
774    // Start with convex hull, then for each edge check if we can "carve in"
775    // by finding interior points that form smaller triangles
776    let hull = convex_hull(&coords);
777
778    if hull.len() < 3 {
779        return Ok(None);
780    }
781
782    // For a reasonable concave hull, we use an edge-refinement approach:
783    // If an edge is too long relative to alpha*average_edge_length,
784    // find the nearest point to the midpoint and insert it.
785    let mut refined = hull.clone();
786    let avg_edge_len = compute_average_edge_length(&refined);
787    let threshold = avg_edge_len * (1.0 + alpha * 2.0);
788
789    let coord_set: HashSet<u64> = coords.iter().map(|c| hash_coordinate(c)).collect();
790
791    // Multiple refinement passes
792    for _ in 0..3 {
793        let mut new_refined = Vec::new();
794        let mut changed = false;
795
796        for i in 0..refined.len() {
797            new_refined.push(refined[i]);
798            let j = (i + 1) % refined.len();
799
800            let edge_len = euclidean_dist(&refined[i], &refined[j]);
801            if edge_len > threshold {
802                // Find the nearest interior point to the midpoint
803                let mid_x = (refined[i].x + refined[j].x) / 2.0;
804                let mid_y = (refined[i].y + refined[j].y) / 2.0;
805                let mid = Coordinate::new_2d(mid_x, mid_y);
806
807                let nearest = coords
808                    .iter()
809                    .filter(|c| {
810                        let h = hash_coordinate(c);
811                        coord_set.contains(&h)
812                            && !is_same_coord(c, &refined[i])
813                            && !is_same_coord(c, &refined[j])
814                    })
815                    .min_by(|a, b| {
816                        let da = euclidean_dist(a, &mid);
817                        let db = euclidean_dist(b, &mid);
818                        da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
819                    });
820
821                if let Some(nearest_pt) = nearest {
822                    let dist_to_mid = euclidean_dist(nearest_pt, &mid);
823                    if dist_to_mid < edge_len / 2.0 {
824                        new_refined.push(*nearest_pt);
825                        changed = true;
826                    }
827                }
828            }
829        }
830
831        refined = new_refined;
832        if !changed {
833            break;
834        }
835    }
836
837    if refined.len() < 3 {
838        return Ok(None);
839    }
840
841    // Close the ring
842    if let Some(first) = refined.first().copied() {
843        refined.push(first);
844    }
845
846    let exterior = LineString::new(refined)
847        .map_err(|e| AlgorithmError::InvalidGeometry(format!("Invalid linestring: {}", e)))?;
848
849    let polygon = Polygon::new(exterior, vec![])
850        .map_err(|e| AlgorithmError::InvalidGeometry(format!("Invalid polygon: {}", e)))?;
851
852    Ok(Some(polygon))
853}
854
855/// Hash a coordinate for set membership testing
856fn hash_coordinate(c: &Coordinate) -> u64 {
857    let x_bits = c.x.to_bits();
858    let y_bits = c.y.to_bits();
859    x_bits.wrapping_mul(31).wrapping_add(y_bits)
860}
861
862/// Check if two coordinates are the same
863fn is_same_coord(a: &Coordinate, b: &Coordinate) -> bool {
864    (a.x - b.x).abs() < 1e-12 && (a.y - b.y).abs() < 1e-12
865}
866
867/// Euclidean distance between two coordinates
868fn euclidean_dist(a: &Coordinate, b: &Coordinate) -> f64 {
869    let dx = a.x - b.x;
870    let dy = a.y - b.y;
871    (dx * dx + dy * dy).sqrt()
872}
873
874/// Compute the average edge length of a polygon boundary
875fn compute_average_edge_length(coords: &[Coordinate]) -> f64 {
876    if coords.len() < 2 {
877        return 0.0;
878    }
879
880    let mut total = 0.0;
881    let n = coords.len();
882    for i in 0..n {
883        let j = (i + 1) % n;
884        total += euclidean_dist(&coords[i], &coords[j]);
885    }
886
887    total / n as f64
888}
889
890/// Compute convex hull using Graham scan
891fn convex_hull(points: &[Coordinate]) -> Vec<Coordinate> {
892    if points.len() < 3 {
893        return points.to_vec();
894    }
895
896    let mut points = points.to_vec();
897
898    // Find the point with lowest y-coordinate (and leftmost if tie)
899    let start_idx = points
900        .iter()
901        .enumerate()
902        .min_by(|(_, a), (_, b)| {
903            a.y.partial_cmp(&b.y)
904                .unwrap_or(std::cmp::Ordering::Equal)
905                .then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
906        })
907        .map(|(idx, _)| idx)
908        .unwrap_or(0);
909
910    points.swap(0, start_idx);
911    let start = points[0];
912
913    // Sort by polar angle with respect to start point
914    points[1..].sort_by(|a, b| {
915        let angle_a = (a.y - start.y).atan2(a.x - start.x);
916        let angle_b = (b.y - start.y).atan2(b.x - start.x);
917        angle_a
918            .partial_cmp(&angle_b)
919            .unwrap_or(std::cmp::Ordering::Equal)
920    });
921
922    // Remove duplicates by angle (keep the farthest)
923    let mut unique_points = vec![points[0]];
924    for i in 1..points.len() {
925        if unique_points.len() >= 2 {
926            let last = unique_points[unique_points.len() - 1];
927            let angle_last = (last.y - start.y).atan2(last.x - start.x);
928            let angle_curr = (points[i].y - start.y).atan2(points[i].x - start.x);
929
930            if (angle_last - angle_curr).abs() < 1e-12 {
931                // Same angle - keep the farther one
932                let dist_last = euclidean_dist(&start, &last);
933                let dist_curr = euclidean_dist(&start, &points[i]);
934                if dist_curr > dist_last {
935                    unique_points.pop();
936                } else {
937                    continue;
938                }
939            }
940        }
941        unique_points.push(points[i]);
942    }
943
944    // Build convex hull
945    let mut hull = Vec::new();
946    for point in unique_points {
947        while hull.len() >= 2 && !is_left_turn(&hull[hull.len() - 2], &hull[hull.len() - 1], &point)
948        {
949            hull.pop();
950        }
951        hull.push(point);
952    }
953
954    hull
955}
956
957/// Check if three points make a left turn (counter-clockwise)
958fn is_left_turn(p1: &Coordinate, p2: &Coordinate, p3: &Coordinate) -> bool {
959    let cross = (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x);
960    cross > 0.0
961}
962
963/// Compute the area of a polygon using the shoelace formula
964fn compute_polygon_area(polygon: &Polygon) -> f64 {
965    let coords = &polygon.exterior.coords;
966    if coords.len() < 3 {
967        return 0.0;
968    }
969
970    let mut area = 0.0;
971    let n = coords.len();
972
973    for i in 0..n {
974        let j = (i + 1) % n;
975        area += coords[i].x * coords[j].y;
976        area -= coords[j].x * coords[i].y;
977    }
978
979    (area / 2.0).abs()
980}
981
982/// Compute the perimeter of a polygon
983fn compute_polygon_perimeter(polygon: &Polygon) -> f64 {
984    let coords = &polygon.exterior.coords;
985    let mut perimeter = 0.0;
986
987    for i in 0..coords.len().saturating_sub(1) {
988        perimeter += euclidean_dist(&coords[i], &coords[i + 1]);
989    }
990
991    perimeter
992}
993
994/// Calculate accessibility score for a node
995/// based on how many facilities/destinations can be reached within cost thresholds
996pub fn accessibility_score(
997    graph: &Graph,
998    node: NodeId,
999    destinations: &[NodeId],
1000    max_cost: f64,
1001) -> Result<AccessibilityResult> {
1002    if graph.get_node(node).is_none() {
1003        return Err(AlgorithmError::InvalidGeometry(format!(
1004            "Node {:?} not found",
1005            node
1006        )));
1007    }
1008
1009    let options = ShortestPathOptions {
1010        max_length: Some(max_cost),
1011        ..Default::default()
1012    };
1013
1014    let sssp = crate::vector::network::dijkstra_single_source(graph, node, &options)?;
1015
1016    let mut reachable_count = 0;
1017    let mut total_cost = 0.0;
1018    let mut destination_costs = Vec::new();
1019
1020    for &dest in destinations {
1021        if let Some(&(cost, _)) = sssp.get(&dest) {
1022            if cost <= max_cost {
1023                reachable_count += 1;
1024                total_cost += cost;
1025                destination_costs.push((dest, cost));
1026            }
1027        }
1028    }
1029
1030    let score = if destinations.is_empty() {
1031        0.0
1032    } else {
1033        reachable_count as f64 / destinations.len() as f64
1034    };
1035
1036    let avg_cost = if reachable_count > 0 {
1037        total_cost / reachable_count as f64
1038    } else {
1039        f64::INFINITY
1040    };
1041
1042    Ok(AccessibilityResult {
1043        node,
1044        score,
1045        reachable_count,
1046        total_destinations: destinations.len(),
1047        average_cost: avg_cost,
1048        destination_costs,
1049    })
1050}
1051
1052/// Result of accessibility analysis
1053#[derive(Debug, Clone)]
1054pub struct AccessibilityResult {
1055    /// The analyzed node
1056    pub node: NodeId,
1057    /// Accessibility score (0.0 to 1.0, fraction of destinations reachable)
1058    pub score: f64,
1059    /// Number of reachable destinations
1060    pub reachable_count: usize,
1061    /// Total number of destinations
1062    pub total_destinations: usize,
1063    /// Average cost to reachable destinations
1064    pub average_cost: f64,
1065    /// Individual destination costs
1066    pub destination_costs: Vec<(NodeId, f64)>,
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    fn create_test_graph() -> Graph {
1074        let mut graph = Graph::new();
1075
1076        // Star pattern: center n0 connected to n1,n2,n3,n4
1077        let n0 = graph.add_node(Coordinate::new_2d(0.0, 0.0));
1078        let n1 = graph.add_node(Coordinate::new_2d(1.0, 0.0));
1079        let n2 = graph.add_node(Coordinate::new_2d(0.0, 1.0));
1080        let n3 = graph.add_node(Coordinate::new_2d(-1.0, 0.0));
1081        let n4 = graph.add_node(Coordinate::new_2d(0.0, -1.0));
1082
1083        let _ = graph.add_edge(n0, n1, 100.0);
1084        let _ = graph.add_edge(n0, n2, 200.0);
1085        let _ = graph.add_edge(n0, n3, 300.0);
1086        let _ = graph.add_edge(n0, n4, 400.0);
1087        // Add some cross-edges
1088        let _ = graph.add_edge(n1, n2, 150.0);
1089
1090        graph
1091    }
1092
1093    #[test]
1094    fn test_service_area_options() {
1095        let options = ServiceAreaOptions::default();
1096        assert_eq!(options.max_cost, 1000.0);
1097        assert!(!options.intervals.is_empty());
1098    }
1099
1100    #[test]
1101    fn test_isochrone_options() {
1102        let options = IsochroneOptions::default();
1103        assert!(options.smooth);
1104        assert_eq!(options.time_intervals.len(), 4);
1105    }
1106
1107    #[test]
1108    fn test_service_area_basic() {
1109        let graph = create_test_graph();
1110        let nodes = graph.sorted_node_ids();
1111        let origin = nodes[0]; // Center node
1112
1113        let options = ServiceAreaOptions {
1114            max_cost: 500.0,
1115            intervals: vec![100.0, 200.0, 300.0, 500.0],
1116            include_unreachable: false,
1117            cost_type: ServiceAreaCostType::Distance,
1118            weight_criteria: None,
1119        };
1120
1121        let sa = calculate_service_area(&graph, origin, &options);
1122        assert!(sa.is_ok());
1123
1124        let service_area = sa.expect("service area");
1125        assert!(!service_area.reachable_nodes.is_empty());
1126
1127        // Check intervals are properly ordered
1128        for i in 1..service_area.intervals.len() {
1129            assert!(service_area.intervals[i].count >= service_area.intervals[i - 1].count);
1130        }
1131    }
1132
1133    #[test]
1134    fn test_service_area_break_values() {
1135        let graph = create_test_graph();
1136        let nodes = graph.sorted_node_ids();
1137        let origin = nodes[0];
1138
1139        let options = ServiceAreaOptions {
1140            max_cost: 500.0,
1141            intervals: vec![100.0, 250.0, 500.0],
1142            include_unreachable: false,
1143            cost_type: ServiceAreaCostType::Distance,
1144            weight_criteria: None,
1145        };
1146
1147        let sa = calculate_service_area(&graph, origin, &options).expect("service area");
1148
1149        assert_eq!(sa.intervals.len(), 3);
1150        // 100.0 interval: only n0 (cost 0) and n1 (cost 100)
1151        assert_eq!(sa.intervals[0].max_cost, 100.0);
1152        // 250.0 interval: also n2 (cost 200)
1153        assert!(sa.intervals[1].count >= sa.intervals[0].count);
1154    }
1155
1156    #[test]
1157    fn test_multi_facility() {
1158        let mut graph = Graph::new();
1159        let n0 = graph.add_node(Coordinate::new_2d(0.0, 0.0));
1160        let n1 = graph.add_node(Coordinate::new_2d(1.0, 0.0));
1161        let n2 = graph.add_node(Coordinate::new_2d(2.0, 0.0));
1162        let n3 = graph.add_node(Coordinate::new_2d(3.0, 0.0));
1163
1164        let _ = graph.add_edge(n0, n1, 1.0);
1165        let _ = graph.add_edge(n1, n2, 1.0);
1166        let _ = graph.add_edge(n2, n3, 1.0);
1167        let _ = graph.add_edge(n3, n2, 1.0);
1168
1169        let options = ServiceAreaOptions {
1170            max_cost: 5.0,
1171            intervals: vec![1.0, 2.0, 5.0],
1172            ..Default::default()
1173        };
1174
1175        let result = calculate_multi_facility_service_area(&graph, &[n0, n3], &options);
1176        assert!(result.is_ok());
1177
1178        let mf = result.expect("multi-facility");
1179        assert_eq!(mf.facility_areas.len(), 2);
1180
1181        // n1 should be nearest to n0 (cost 1)
1182        if let Some(&(facility, _cost)) = mf.nearest_facility.get(&n1) {
1183            assert_eq!(facility, n0);
1184        }
1185    }
1186
1187    #[test]
1188    fn test_convex_hull() {
1189        let points = vec![
1190            Coordinate::new_2d(0.0, 0.0),
1191            Coordinate::new_2d(1.0, 0.0),
1192            Coordinate::new_2d(0.5, 0.5),
1193            Coordinate::new_2d(0.0, 1.0),
1194        ];
1195
1196        let hull = convex_hull(&points);
1197        assert!(hull.len() >= 3);
1198    }
1199
1200    #[test]
1201    fn test_is_left_turn() {
1202        let p1 = Coordinate::new_2d(0.0, 0.0);
1203        let p2 = Coordinate::new_2d(1.0, 0.0);
1204        let p3 = Coordinate::new_2d(0.5, 1.0);
1205
1206        assert!(is_left_turn(&p1, &p2, &p3));
1207    }
1208
1209    #[test]
1210    fn test_polygon_area() {
1211        // Unit square: area should be 1.0
1212        let coords = vec![
1213            Coordinate::new_2d(0.0, 0.0),
1214            Coordinate::new_2d(1.0, 0.0),
1215            Coordinate::new_2d(1.0, 1.0),
1216            Coordinate::new_2d(0.0, 1.0),
1217            Coordinate::new_2d(0.0, 0.0),
1218        ];
1219        let exterior = LineString::new(coords).expect("linestring");
1220        let polygon = Polygon::new(exterior, vec![]).expect("polygon");
1221        let area = compute_polygon_area(&polygon);
1222        assert!((area - 1.0).abs() < 1e-10);
1223    }
1224
1225    #[test]
1226    fn test_isochrone_generation() {
1227        let graph = create_test_graph();
1228        let nodes = graph.sorted_node_ids();
1229        let origin = nodes[0];
1230
1231        let options = IsochroneOptions {
1232            time_intervals: vec![150.0, 300.0, 500.0],
1233            smooth: false,
1234            smoothing_factor: 0.5,
1235            polygon_method: IsochronePolygonMethod::ConvexHull,
1236        };
1237
1238        let result = calculate_isochrones(&graph, origin, &options);
1239        assert!(result.is_ok());
1240
1241        let isochrones = result.expect("isochrones");
1242        assert_eq!(isochrones.len(), 3);
1243
1244        // Later intervals should have more or equal nodes
1245        for i in 1..isochrones.len() {
1246            assert!(isochrones[i].nodes.len() >= isochrones[i - 1].nodes.len());
1247        }
1248    }
1249
1250    #[test]
1251    fn test_accessibility_score() {
1252        let mut graph = Graph::new();
1253        let n0 = graph.add_node(Coordinate::new_2d(0.0, 0.0));
1254        let n1 = graph.add_node(Coordinate::new_2d(1.0, 0.0));
1255        let n2 = graph.add_node(Coordinate::new_2d(2.0, 0.0));
1256        let n3 = graph.add_node(Coordinate::new_2d(10.0, 0.0)); // Unreachable within cost
1257
1258        let _ = graph.add_edge(n0, n1, 1.0);
1259        let _ = graph.add_edge(n1, n2, 1.0);
1260
1261        let result = accessibility_score(&graph, n0, &[n1, n2, n3], 5.0);
1262        assert!(result.is_ok());
1263
1264        let acc = result.expect("accessibility");
1265        assert_eq!(acc.reachable_count, 2); // n1 and n2 reachable
1266        assert_eq!(acc.total_destinations, 3);
1267        assert!((acc.score - 2.0 / 3.0).abs() < 1e-10);
1268    }
1269
1270    #[test]
1271    fn test_drive_time_polygons() {
1272        let graph = create_test_graph();
1273        let nodes = graph.sorted_node_ids();
1274        let origin = nodes[0];
1275
1276        let result = calculate_drive_time_polygons(
1277            &graph,
1278            origin,
1279            &[60.0, 120.0, 300.0], // 1, 2, 5 minutes
1280            50.0,                  // 50 km/h
1281        );
1282        assert!(result.is_ok());
1283
1284        let polygons = result.expect("drive time polygons");
1285        assert_eq!(polygons.len(), 3);
1286    }
1287
1288    #[test]
1289    fn test_multi_isochrones() {
1290        let graph = create_test_graph();
1291        let nodes = graph.sorted_node_ids();
1292
1293        let options = IsochroneOptions {
1294            time_intervals: vec![200.0, 500.0],
1295            smooth: false,
1296            ..Default::default()
1297        };
1298
1299        let result = calculate_multi_isochrones(&graph, &[nodes[0], nodes[3]], &options);
1300        assert!(result.is_ok());
1301
1302        let all = result.expect("multi isochrones");
1303        assert_eq!(all.len(), 2); // One set per origin
1304    }
1305
1306    #[test]
1307    fn test_overlap_zone_detection() {
1308        let mut graph = Graph::new();
1309        let n0 = graph.add_node(Coordinate::new_2d(0.0, 0.0));
1310        let n1 = graph.add_node(Coordinate::new_2d(1.0, 0.0));
1311        let n2 = graph.add_node(Coordinate::new_2d(2.0, 0.0));
1312
1313        // Both facilities can reach n1
1314        let _ = graph.add_edge(n0, n1, 1.0);
1315        let _ = graph.add_edge(n2, n1, 1.0);
1316
1317        let options = ServiceAreaOptions {
1318            max_cost: 5.0,
1319            intervals: vec![2.0],
1320            ..Default::default()
1321        };
1322
1323        let result = calculate_multi_facility_service_area(&graph, &[n0, n2], &options);
1324        assert!(result.is_ok());
1325
1326        let mf = result.expect("multi-facility");
1327        // n1 should be in overlap
1328        assert!(!mf.overlap_zones.is_empty());
1329    }
1330}