1use glam::Vec3;
8use std::cmp::Ordering;
9use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet, VecDeque};
10
11pub type GraphProperties = BTreeMap<String, serde_json::Value>;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct TemporalWindow {
15 pub start: u64,
16 pub end: u64,
17}
18
19impl TemporalWindow {
20 pub fn overlaps(self, begin_ts: u64, end_ts: u64) -> bool {
21 if end_ts != 0 && end_ts <= begin_ts {
24 return false;
25 }
26
27 begin_ts < self.end && (end_ts == 0 || end_ts > self.start)
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum SpatialRegion {
33 Sphere { center: Vec3, radius: f32 },
34 Aabb { min: Vec3, max: Vec3 },
35}
36
37impl SpatialRegion {
38 pub fn contains(self, point: Vec3) -> bool {
39 match self {
40 SpatialRegion::Sphere { center, radius } => point.distance(center) <= radius,
41 SpatialRegion::Aabb { min, max } => {
42 point.x >= min.x
43 && point.x <= max.x
44 && point.y >= min.y
45 && point.y <= max.y
46 && point.z >= min.z
47 && point.z <= max.z
48 }
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54pub struct TraversalContext4D {
55 pub time_window: Option<TemporalWindow>,
56 pub spatial_region: Option<SpatialRegion>,
57 pub spatial_candidates: Option<HashSet<u64>>,
60 pub graph_weight: f32,
61 pub spatial_weight: f32,
62 pub temporal_weight: f32,
63}
64
65impl Default for TraversalContext4D {
66 fn default() -> Self {
67 Self {
68 time_window: None,
69 spatial_region: None,
70 spatial_candidates: None,
71 graph_weight: 1.0,
72 spatial_weight: 0.0,
73 temporal_weight: 0.0,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct TemporalEdge {
80 pub dst: u64,
81 pub weight: f32,
82 pub begin_ts: u64,
83 pub end_ts: u64,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub struct GraphNode4D {
88 pub id: u64,
89 pub x: f32,
90 pub y: f32,
91 pub z: f32,
92 pub begin_ts: u64,
93 pub end_ts: u64,
94 pub properties: GraphProperties,
95 pub successors: Vec<TemporalEdge>,
96}
97
98impl GraphNode4D {
99 pub fn position(&self) -> Vec3 {
100 Vec3::new(self.x, self.y, self.z)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub struct GraphPath4D {
106 pub node_ids: Vec<u64>,
107 pub total_cost: f32,
108 pub heuristic_cost: f32,
109 pub actual_cost: f32,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TemporalJourney4D {
114 pub node_ids: Vec<u64>,
115 pub departure_time: u64,
116 pub arrival_time: u64,
117 pub duration: u64,
118}
119
120#[derive(Debug, Clone, PartialEq)]
121pub struct TemporalArrival4D {
122 pub node_id: u64,
123 pub arrival_time: u64,
124 pub cost: f32,
125 pub path: Vec<u64>,
126}
127
128#[derive(Debug, Clone, PartialEq)]
129pub struct TemporalDijkstraResult4D {
130 pub start_node: u64,
131 pub departure_time: u64,
132 pub reachable: Vec<TemporalArrival4D>,
133 pub unreachable: Vec<u64>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
137struct QueueNode4D {
138 node_id: u64,
139 f_score: f32,
140 g_score: f32,
141}
142
143impl Eq for QueueNode4D {}
144
145impl Ord for QueueNode4D {
146 fn cmp(&self, other: &Self) -> Ordering {
147 other
148 .f_score
149 .partial_cmp(&self.f_score)
150 .unwrap_or(Ordering::Equal)
151 }
152}
153
154impl PartialOrd for QueueNode4D {
155 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
156 Some(self.cmp(other))
157 }
158}
159
160fn node_is_valid(node: &GraphNode4D, ctx: &TraversalContext4D) -> bool {
161 let mut valid = true;
162 if let Some(window) = ctx.time_window {
163 if !window.overlaps(node.begin_ts, node.end_ts) {
164 valid = false;
165 }
166 }
167
168 if valid {
169 match (&ctx.spatial_candidates, ctx.spatial_region) {
170 (Some(candidates), _) => {
171 if !candidates.contains(&node.id) {
172 valid = false;
173 }
174 }
175 (None, Some(region)) => {
176 if !region.contains(node.position()) {
177 valid = false;
178 }
179 }
180 (None, None) => {}
181 }
182 }
183
184 valid
185}
186
187fn edge_is_valid(edge: TemporalEdge, ctx: &TraversalContext4D) -> bool {
188 ctx.time_window
189 .map(|window| window.overlaps(edge.begin_ts, edge.end_ts))
190 .unwrap_or(true)
191}
192
193fn instant_in_validity(ts: u64, begin_ts: u64, end_ts: u64) -> bool {
194 ts >= begin_ts && (end_ts == 0 || ts <= end_ts)
195}
196
197fn traversal_duration(edge: TemporalEdge) -> Option<u64> {
198 if !edge.weight.is_finite() || edge.weight < 0.0 {
199 return None;
200 }
201 Some(edge.weight.ceil() as u64)
202}
203
204fn edge_departure_and_arrival(edge: TemporalEdge, current_time: u64) -> Option<(u64, u64)> {
205 let departure = current_time.max(edge.begin_ts);
206 if edge.end_ts != 0 && departure > edge.end_ts {
207 return None;
208 }
209
210 let arrival = departure.checked_add(traversal_duration(edge)?)?;
211 if edge.end_ts != 0 && arrival > edge.end_ts {
212 return None;
213 }
214
215 Some((departure, arrival))
216}
217
218fn temporal_node_is_usable(
219 node: &GraphNode4D,
220 arrival_time: u64,
221 region: Option<SpatialRegion>,
222) -> bool {
223 instant_in_validity(arrival_time, node.begin_ts, node.end_ts)
224 && region
225 .map(|spatial_region| spatial_region.contains(node.position()))
226 .unwrap_or(true)
227}
228
229fn temporal_delay(edge: TemporalEdge, ctx: &TraversalContext4D) -> f32 {
230 match ctx.time_window {
231 Some(window) if edge.begin_ts > window.start => (edge.begin_ts - window.start) as f32,
232 _ => 0.0,
233 }
234}
235
236fn edge_cost(
237 current: &GraphNode4D,
238 next: &GraphNode4D,
239 edge: TemporalEdge,
240 ctx: &TraversalContext4D,
241) -> f32 {
242 let graph = ctx.graph_weight * edge.weight.max(0.0);
243 let spatial = ctx.spatial_weight * current.position().distance(next.position());
244 let temporal = ctx.temporal_weight * temporal_delay(edge, ctx);
245 graph + spatial + temporal
246}
247
248fn heuristic(current: &GraphNode4D, goal: &GraphNode4D, ctx: &TraversalContext4D) -> f32 {
249 ctx.spatial_weight * current.position().distance(goal.position())
250}
251
252pub fn reachable_4d(nodes: &[GraphNode4D], start_id: u64, ctx: &TraversalContext4D) -> Vec<u64> {
253 let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
254 let Some(start) = node_map.get(&start_id) else {
255 return Vec::new();
256 };
257 if !node_is_valid(start, ctx) {
258 return Vec::new();
259 }
260
261 let mut visited = HashSet::new();
262 let mut queue = VecDeque::from([start_id]);
263 let mut result = Vec::new();
264
265 while let Some(current_id) = queue.pop_front() {
266 if !visited.insert(current_id) {
267 continue;
268 }
269 let Some(current) = node_map.get(¤t_id) else {
270 continue;
271 };
272 if !node_is_valid(current, ctx) {
273 continue;
274 }
275
276 result.push(current_id);
277
278 for edge in ¤t.successors {
279 if !edge_is_valid(*edge, ctx) || visited.contains(&edge.dst) {
280 continue;
281 }
282 if let Some(next) = node_map.get(&edge.dst) {
283 if node_is_valid(next, ctx) {
284 queue.push_back(edge.dst);
285 }
286 }
287 }
288 }
289
290 result
291}
292
293pub fn astar_find_path_4d(
294 nodes: &[GraphNode4D],
295 start_id: u64,
296 goal_id: u64,
297 ctx: &TraversalContext4D,
298) -> Option<GraphPath4D> {
299 let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
300 let start = *node_map.get(&start_id)?;
301 let goal = *node_map.get(&goal_id)?;
302
303 if !node_is_valid(start, ctx) || !node_is_valid(goal, ctx) {
304 return None;
305 }
306
307 let initial_h = heuristic(start, goal, ctx);
308 let mut open_set = BinaryHeap::from([QueueNode4D {
309 node_id: start_id,
310 f_score: initial_h,
311 g_score: 0.0,
312 }]);
313 let mut closed = HashSet::new();
314 let mut g_score = HashMap::from([(start_id, 0.0)]);
315 let mut came_from = HashMap::new();
316
317 while let Some(current) = open_set.pop() {
318 if !closed.insert(current.node_id) {
319 continue;
320 }
321
322 if current.node_id == goal_id {
323 let mut path = vec![goal_id];
324 let mut cursor = goal_id;
325 while let Some(prev) = came_from.get(&cursor).copied() {
326 path.push(prev);
327 cursor = prev;
328 }
329 path.reverse();
330 return Some(GraphPath4D {
331 node_ids: path,
332 total_cost: current.f_score,
333 heuristic_cost: initial_h,
334 actual_cost: current.g_score,
335 });
336 }
337
338 let current_node = *node_map.get(¤t.node_id)?;
339 for edge in ¤t_node.successors {
340 if !edge_is_valid(*edge, ctx) || closed.contains(&edge.dst) {
341 continue;
342 }
343 let Some(next) = node_map.get(&edge.dst).copied() else {
344 continue;
345 };
346 if !node_is_valid(next, ctx) {
347 continue;
348 }
349
350 let tentative_g = current.g_score + edge_cost(current_node, next, *edge, ctx);
351 let existing_g = g_score.get(&edge.dst).copied().unwrap_or(f32::INFINITY);
352 if tentative_g < existing_g {
353 came_from.insert(edge.dst, current.node_id);
354 g_score.insert(edge.dst, tentative_g);
355 open_set.push(QueueNode4D {
356 node_id: edge.dst,
357 f_score: tentative_g + heuristic(next, goal, ctx),
358 g_score: tentative_g,
359 });
360 }
361 }
362 }
363
364 None
365}
366
367pub fn strongly_connected_components_4d(
368 nodes: &[GraphNode4D],
369 ctx: &TraversalContext4D,
370) -> Vec<Vec<u64>> {
371 struct Tarjan<'a> {
372 nodes: HashMap<u64, &'a GraphNode4D>,
373 ctx: &'a TraversalContext4D,
374 index: usize,
375 stack: Vec<u64>,
376 on_stack: HashSet<u64>,
377 indices: HashMap<u64, usize>,
378 lowlinks: HashMap<u64, usize>,
379 components: Vec<Vec<u64>>,
380 }
381
382 impl<'a> Tarjan<'a> {
383 fn strong_connect(&mut self, node_id: u64) {
384 self.indices.insert(node_id, self.index);
385 self.lowlinks.insert(node_id, self.index);
386 self.index += 1;
387 self.stack.push(node_id);
388 self.on_stack.insert(node_id);
389
390 let Some(node) = self.nodes.get(&node_id) else {
391 return;
392 };
393 for edge in &node.successors {
394 if !edge_is_valid(*edge, self.ctx) {
395 continue;
396 }
397 let Some(next) = self.nodes.get(&edge.dst).copied() else {
398 continue;
399 };
400 if !node_is_valid(next, self.ctx) {
401 continue;
402 }
403
404 if !self.indices.contains_key(&edge.dst) {
405 self.strong_connect(edge.dst);
406 let low = self.lowlinks[&node_id].min(self.lowlinks[&edge.dst]);
407 self.lowlinks.insert(node_id, low);
408 } else if self.on_stack.contains(&edge.dst) {
409 let low = self.lowlinks[&node_id].min(self.indices[&edge.dst]);
410 self.lowlinks.insert(node_id, low);
411 }
412 }
413
414 if self.indices[&node_id] == self.lowlinks[&node_id] {
415 let mut component = Vec::new();
416 while let Some(member) = self.stack.pop() {
417 self.on_stack.remove(&member);
418 component.push(member);
419 if member == node_id {
420 break;
421 }
422 }
423 component.sort_unstable();
424 self.components.push(component);
425 }
426 }
427 }
428
429 let node_map: HashMap<u64, &GraphNode4D> = nodes
430 .iter()
431 .filter(|node| node_is_valid(node, ctx))
432 .map(|node| (node.id, node))
433 .collect();
434 let mut ids: Vec<u64> = node_map.keys().copied().collect();
435 ids.sort_unstable();
436
437 let mut tarjan = Tarjan {
438 nodes: node_map,
439 ctx,
440 index: 0,
441 stack: Vec::new(),
442 on_stack: HashSet::new(),
443 indices: HashMap::new(),
444 lowlinks: HashMap::new(),
445 components: Vec::new(),
446 };
447
448 for id in ids {
449 if !tarjan.indices.contains_key(&id) {
450 tarjan.strong_connect(id);
451 }
452 }
453
454 tarjan.components.sort_by_key(|component| component[0]);
455 tarjan.components
456}
457
458pub fn earliest_arrival_path_4d(
459 nodes: &[GraphNode4D],
460 start_id: u64,
461 goal_id: u64,
462 departure_time: u64,
463 spatial_region: Option<SpatialRegion>,
464) -> Option<TemporalJourney4D> {
465 let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|node| (node.id, node)).collect();
466 let start = *node_map.get(&start_id)?;
467 if !temporal_node_is_usable(start, departure_time, spatial_region) {
468 return None;
469 }
470
471 let mut best_arrival = HashMap::from([(start_id, departure_time)]);
472 let mut came_from = HashMap::new();
473 let mut queue = BinaryHeap::from([TemporalQueueNode {
474 node_id: start_id,
475 arrival_time: departure_time,
476 }]);
477
478 while let Some(current) = queue.pop() {
479 if current.arrival_time > best_arrival[¤t.node_id] {
480 continue;
481 }
482 if current.node_id == goal_id {
483 return Some(reconstruct_temporal_journey(
484 &came_from,
485 start_id,
486 goal_id,
487 departure_time,
488 current.arrival_time,
489 ));
490 }
491
492 let current_node = *node_map.get(¤t.node_id)?;
493 for edge in ¤t_node.successors {
494 let Some(next) = node_map.get(&edge.dst).copied() else {
495 continue;
496 };
497 let Some((_, arrival_time)) = edge_departure_and_arrival(*edge, current.arrival_time)
498 else {
499 continue;
500 };
501 if !temporal_node_is_usable(next, arrival_time, spatial_region) {
502 continue;
503 }
504
505 if arrival_time < best_arrival.get(&edge.dst).copied().unwrap_or(u64::MAX) {
506 best_arrival.insert(edge.dst, arrival_time);
507 came_from.insert(edge.dst, current.node_id);
508 queue.push(TemporalQueueNode {
509 node_id: edge.dst,
510 arrival_time,
511 });
512 }
513 }
514 }
515
516 None
517}
518
519pub fn fastest_temporal_path_4d(
520 nodes: &[GraphNode4D],
521 start_id: u64,
522 goal_id: u64,
523 earliest_departure: u64,
524 latest_departure: u64,
525 spatial_region: Option<SpatialRegion>,
526) -> Option<TemporalJourney4D> {
527 const MAX_WINDOW: u64 = 100_000;
528 if earliest_departure > latest_departure {
529 return None;
530 }
531 if latest_departure.saturating_sub(earliest_departure) > MAX_WINDOW {
532 return None;
533 }
534
535 let mut best: Option<TemporalJourney4D> = None;
536
537 for departure_time in earliest_departure..=latest_departure {
538 let Some(journey) =
539 earliest_arrival_path_4d(nodes, start_id, goal_id, departure_time, spatial_region)
540 else {
541 continue;
542 };
543
544 let should_replace = best
545 .as_ref()
546 .map(|current| {
547 journey.duration < current.duration
548 || (journey.duration == current.duration
549 && journey.arrival_time < current.arrival_time)
550 })
551 .unwrap_or(true);
552 if should_replace {
553 best = Some(journey);
554 }
555 }
556
557 best
558}
559
560pub fn articulation_points_4d(nodes: &[GraphNode4D], ctx: &TraversalContext4D) -> Vec<u64> {
561 let adjacency = active_undirected_adjacency(nodes, ctx);
562 let mut ids: Vec<u64> = adjacency.keys().copied().collect();
563 ids.sort_unstable();
564
565 let mut finder = LowLinkFinder {
566 adjacency: &adjacency,
567 time: 0,
568 visited: HashSet::new(),
569 discovery: HashMap::new(),
570 low: HashMap::new(),
571 parent: HashMap::new(),
572 articulation_points: HashSet::new(),
573 bridges: Vec::new(),
574 };
575
576 for id in ids {
577 if !finder.visited.contains(&id) {
578 finder.visit(id);
579 }
580 }
581
582 let mut points: Vec<u64> = finder.articulation_points.into_iter().collect();
583 points.sort_unstable();
584 points
585}
586
587pub fn bridges_4d(nodes: &[GraphNode4D], ctx: &TraversalContext4D) -> Vec<(u64, u64)> {
588 let adjacency = active_undirected_adjacency(nodes, ctx);
589 let mut ids: Vec<u64> = adjacency.keys().copied().collect();
590 ids.sort_unstable();
591
592 let mut finder = LowLinkFinder {
593 adjacency: &adjacency,
594 time: 0,
595 visited: HashSet::new(),
596 discovery: HashMap::new(),
597 low: HashMap::new(),
598 parent: HashMap::new(),
599 articulation_points: HashSet::new(),
600 bridges: Vec::new(),
601 };
602
603 for id in ids {
604 if !finder.visited.contains(&id) {
605 finder.visit(id);
606 }
607 }
608
609 finder.bridges.sort_unstable();
610 finder.bridges
611}
612
613pub fn time_dependent_dijkstra_4d(
614 nodes: &[GraphNode4D],
615 start_id: u64,
616 departure_time: u64,
617 spatial_region: Option<SpatialRegion>,
618) -> Option<TemporalDijkstraResult4D> {
619 let node_map: HashMap<u64, &GraphNode4D> = nodes
620 .iter()
621 .filter(|node| {
622 spatial_region
623 .map(|region| region.contains(node.position()))
624 .unwrap_or(true)
625 })
626 .map(|node| (node.id, node))
627 .collect();
628 let start = *node_map.get(&start_id)?;
629 if !instant_in_validity(departure_time, start.begin_ts, start.end_ts) {
630 return None;
631 }
632
633 let mut best_arrival = HashMap::from([(start_id, departure_time)]);
634 let mut came_from = HashMap::new();
635 let mut queue = BinaryHeap::from([TemporalQueueNode {
636 node_id: start_id,
637 arrival_time: departure_time,
638 }]);
639
640 while let Some(current) = queue.pop() {
641 if current.arrival_time > best_arrival[¤t.node_id] {
642 continue;
643 }
644
645 let current_node = *node_map.get(¤t.node_id)?;
646 for edge in ¤t_node.successors {
647 let Some(next) = node_map.get(&edge.dst).copied() else {
648 continue;
649 };
650 let Some((_, arrival_time)) = edge_departure_and_arrival(*edge, current.arrival_time)
651 else {
652 continue;
653 };
654 if !instant_in_validity(arrival_time, next.begin_ts, next.end_ts) {
655 continue;
656 }
657
658 if arrival_time < best_arrival.get(&edge.dst).copied().unwrap_or(u64::MAX) {
659 best_arrival.insert(edge.dst, arrival_time);
660 came_from.insert(edge.dst, current.node_id);
661 queue.push(TemporalQueueNode {
662 node_id: edge.dst,
663 arrival_time,
664 });
665 }
666 }
667 }
668
669 let mut reachable: Vec<TemporalArrival4D> = best_arrival
670 .iter()
671 .map(|(node_id, arrival_time)| TemporalArrival4D {
672 node_id: *node_id,
673 arrival_time: *arrival_time,
674 cost: arrival_time.saturating_sub(departure_time) as f32,
675 path: reconstruct_node_path(&came_from, start_id, *node_id),
676 })
677 .collect();
678 reachable.sort_by_key(|arrival| (arrival.arrival_time, arrival.node_id));
679
680 let mut unreachable: Vec<u64> = node_map
681 .keys()
682 .copied()
683 .filter(|id| !best_arrival.contains_key(id))
684 .collect();
685 unreachable.sort_unstable();
686
687 Some(TemporalDijkstraResult4D {
688 start_node: start_id,
689 departure_time,
690 reachable,
691 unreachable,
692 })
693}
694
695fn active_undirected_adjacency(
696 nodes: &[GraphNode4D],
697 ctx: &TraversalContext4D,
698) -> HashMap<u64, Vec<u64>> {
699 let node_map: HashMap<u64, &GraphNode4D> = nodes
700 .iter()
701 .filter(|node| node_is_valid(node, ctx))
702 .map(|node| (node.id, node))
703 .collect();
704 let mut adjacency: HashMap<u64, Vec<u64>> = node_map
705 .keys()
706 .copied()
707 .map(|id| (id, Vec::new()))
708 .collect();
709
710 for node in node_map.values() {
711 for edge in &node.successors {
712 if !edge_is_valid(*edge, ctx) || !node_map.contains_key(&edge.dst) {
713 continue;
714 }
715 add_undirected_edge(&mut adjacency, node.id, edge.dst);
716 }
717 }
718
719 for neighbors in adjacency.values_mut() {
720 neighbors.sort_unstable();
721 neighbors.dedup();
722 }
723
724 adjacency
725}
726
727fn add_undirected_edge(adjacency: &mut HashMap<u64, Vec<u64>>, a: u64, b: u64) {
728 if a == b {
729 return;
730 }
731 adjacency.entry(a).or_default().push(b);
732 adjacency.entry(b).or_default().push(a);
733}
734
735struct LowLinkFinder<'a> {
736 adjacency: &'a HashMap<u64, Vec<u64>>,
737 time: usize,
738 visited: HashSet<u64>,
739 discovery: HashMap<u64, usize>,
740 low: HashMap<u64, usize>,
741 parent: HashMap<u64, u64>,
742 articulation_points: HashSet<u64>,
743 bridges: Vec<(u64, u64)>,
744}
745
746impl LowLinkFinder<'_> {
747 fn visit(&mut self, node_id: u64) {
748 self.visited.insert(node_id);
749 self.discovery.insert(node_id, self.time);
750 self.low.insert(node_id, self.time);
751 self.time += 1;
752
753 let mut child_count = 0;
754 let neighbors = self.adjacency.get(&node_id).cloned().unwrap_or_default();
755 for next_id in neighbors {
756 if !self.visited.contains(&next_id) {
757 child_count += 1;
758 self.parent.insert(next_id, node_id);
759 self.visit(next_id);
760
761 let next_low = self.low[&next_id];
762 let node_low = self.low[&node_id].min(next_low);
763 self.low.insert(node_id, node_low);
764
765 let is_root = !self.parent.contains_key(&node_id);
766 if is_root && child_count > 1 {
767 self.articulation_points.insert(node_id);
768 }
769 if !is_root && next_low >= self.discovery[&node_id] {
770 self.articulation_points.insert(node_id);
771 }
772 if next_low > self.discovery[&node_id] {
773 self.bridges
774 .push((node_id.min(next_id), node_id.max(next_id)));
775 }
776 } else if self.parent.get(&node_id).copied() != Some(next_id) {
777 let node_low = self.low[&node_id].min(self.discovery[&next_id]);
778 self.low.insert(node_id, node_low);
779 }
780 }
781 }
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
785struct TemporalQueueNode {
786 node_id: u64,
787 arrival_time: u64,
788}
789
790impl Ord for TemporalQueueNode {
791 fn cmp(&self, other: &Self) -> Ordering {
792 other
793 .arrival_time
794 .cmp(&self.arrival_time)
795 .then_with(|| other.node_id.cmp(&self.node_id))
796 }
797}
798
799impl PartialOrd for TemporalQueueNode {
800 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
801 Some(self.cmp(other))
802 }
803}
804
805fn reconstruct_temporal_journey(
806 came_from: &HashMap<u64, u64>,
807 start_id: u64,
808 goal_id: u64,
809 departure_time: u64,
810 arrival_time: u64,
811) -> TemporalJourney4D {
812 let mut node_ids = vec![goal_id];
813 let mut cursor = goal_id;
814 while cursor != start_id {
815 let Some(prev) = came_from.get(&cursor).copied() else {
816 break;
817 };
818 node_ids.push(prev);
819 cursor = prev;
820 }
821 node_ids.reverse();
822
823 TemporalJourney4D {
824 node_ids,
825 departure_time,
826 arrival_time,
827 duration: arrival_time.saturating_sub(departure_time),
828 }
829}
830
831fn reconstruct_node_path(came_from: &HashMap<u64, u64>, start_id: u64, target_id: u64) -> Vec<u64> {
832 let mut node_ids = vec![target_id];
833 let mut cursor = target_id;
834 while cursor != start_id {
835 let Some(prev) = came_from.get(&cursor).copied() else {
836 break;
837 };
838 node_ids.push(prev);
839 cursor = prev;
840 }
841 node_ids.reverse();
842 node_ids
843}
844
845#[derive(Debug, Clone)]
850pub struct SpatialIndex {
851 octree: crate::spatial::octree::Octree,
852}
853
854impl SpatialIndex {
855 pub fn from_nodes(nodes: &[GraphNode4D]) -> Self {
857 use crate::storage::data_structures::NodePoint;
858 let points: Vec<NodePoint> = nodes
859 .iter()
860 .map(|n| NodePoint {
861 id: n.id,
862 x: n.x,
863 y: n.y,
864 z: n.z,
865 })
866 .collect();
867 SpatialIndex {
868 octree: crate::spatial::octree::Octree::from_nodes(&points),
869 }
870 }
871
872 pub fn prefilter(&self, region: &SpatialRegion) -> HashSet<u64> {
877 use crate::spatial::octree::BoundingBox;
878 match region {
879 SpatialRegion::Sphere { center, radius } => self
880 .octree
881 .query_sphere(*center, *radius)
882 .into_iter()
883 .map(|np| np.id)
884 .collect(),
885 SpatialRegion::Aabb { min, max } => {
886 let bbox = BoundingBox::new(*min, *max);
887 self.octree
888 .query_aabb(&bbox)
889 .into_iter()
890 .map(|np| np.id)
891 .collect()
892 }
893 }
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900
901 fn make_test_nodes() -> Vec<GraphNode4D> {
902 vec![
903 GraphNode4D {
904 id: 1,
905 x: 0.0,
906 y: 0.0,
907 z: 0.0,
908 begin_ts: 0,
909 end_ts: 0,
910 properties: GraphProperties::new(),
911 successors: vec![TemporalEdge {
912 dst: 2,
913 weight: 1.0,
914 begin_ts: 0,
915 end_ts: 0,
916 }],
917 },
918 GraphNode4D {
919 id: 2,
920 x: 3.0,
921 y: 0.0,
922 z: 0.0,
923 begin_ts: 0,
924 end_ts: 0,
925 properties: GraphProperties::new(),
926 successors: vec![TemporalEdge {
927 dst: 3,
928 weight: 1.0,
929 begin_ts: 0,
930 end_ts: 0,
931 }],
932 },
933 GraphNode4D {
934 id: 3,
935 x: 10.0,
936 y: 10.0,
937 z: 10.0,
938 begin_ts: 0,
939 end_ts: 0,
940 properties: GraphProperties::new(),
941 successors: vec![],
942 },
943 GraphNode4D {
944 id: 4,
945 x: 50.0,
946 y: 50.0,
947 z: 50.0,
948 begin_ts: 0,
949 end_ts: 0,
950 properties: GraphProperties::new(),
951 successors: vec![],
952 },
953 ]
954 }
955
956 #[test]
957 fn test_spatial_index_prefilter_sphere() {
958 let nodes = make_test_nodes();
959 let index = SpatialIndex::from_nodes(&nodes);
960 let region = SpatialRegion::Sphere {
961 center: Vec3::new(0.0, 0.0, 0.0),
962 radius: 5.0,
963 };
964 let candidates = index.prefilter(®ion);
965
966 assert!(
969 candidates.contains(&1),
970 "node 1 at origin should be in sphere"
971 );
972 assert!(
973 candidates.contains(&2),
974 "node 2 at (3,0,0) should be in sphere"
975 );
976 assert!(
977 !candidates.contains(&3),
978 "node 3 at (10,10,10) should be outside sphere"
979 );
980 assert!(
981 !candidates.contains(&4),
982 "node 4 at (50,50,50) should be outside sphere"
983 );
984 }
985
986 #[test]
987 fn test_spatial_index_prefilter_aabb() {
988 let nodes = make_test_nodes();
989 let index = SpatialIndex::from_nodes(&nodes);
990 let region = SpatialRegion::Aabb {
991 min: Vec3::new(-1.0, -1.0, -1.0),
992 max: Vec3::new(11.0, 11.0, 11.0),
993 };
994 let candidates = index.prefilter(®ion);
995
996 assert!(candidates.contains(&1), "node 1 should be in AABB");
998 assert!(candidates.contains(&2), "node 2 should be in AABB");
999 assert!(candidates.contains(&3), "node 3 should be in AABB");
1000 assert!(
1001 !candidates.contains(&4),
1002 "node 4 at (50,50,50) should be outside AABB"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_prefilter_matches_manual_containment() {
1008 let nodes = make_test_nodes();
1009 let index = SpatialIndex::from_nodes(&nodes);
1010 let region = SpatialRegion::Sphere {
1011 center: Vec3::new(0.0, 0.0, 0.0),
1012 radius: 12.0,
1013 };
1014
1015 let candidates = index.prefilter(®ion);
1016
1017 for node in &nodes {
1019 let in_region = region.contains(node.position());
1020 let in_candidates = candidates.contains(&node.id);
1021 assert_eq!(
1022 in_region, in_candidates,
1023 "Mismatch for node {}: manual={} candidates={}",
1024 node.id, in_region, in_candidates
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn test_node_is_valid_with_candidates_matches_without() {
1031 let nodes = make_test_nodes();
1032 let index = SpatialIndex::from_nodes(&nodes);
1033 let region = SpatialRegion::Sphere {
1034 center: Vec3::new(0.0, 0.0, 0.0),
1035 radius: 5.0,
1036 };
1037
1038 let ctx_plain = TraversalContext4D {
1039 spatial_region: Some(region),
1040 ..Default::default()
1041 };
1042 let ctx_indexed = TraversalContext4D {
1043 spatial_region: Some(region),
1044 spatial_candidates: Some(index.prefilter(®ion)),
1045 ..Default::default()
1046 };
1047
1048 for node in &nodes {
1049 assert_eq!(
1050 node_is_valid(node, &ctx_plain),
1051 node_is_valid(node, &ctx_indexed),
1052 "Mismatch for node {}: plain vs indexed",
1053 node.id
1054 );
1055 }
1056 }
1057
1058 #[test]
1059 fn test_reachable_4d_with_octree_matches_without() {
1060 let nodes = make_test_nodes();
1061 let index = SpatialIndex::from_nodes(&nodes);
1062 let region = SpatialRegion::Sphere {
1063 center: Vec3::new(0.0, 0.0, 0.0),
1064 radius: 5.0,
1065 };
1066
1067 let ctx_plain = TraversalContext4D {
1068 spatial_region: Some(region),
1069 ..Default::default()
1070 };
1071 let ctx_indexed = TraversalContext4D {
1072 spatial_region: Some(region),
1073 spatial_candidates: Some(index.prefilter(®ion)),
1074 ..Default::default()
1075 };
1076
1077 let result_plain = reachable_4d(&nodes, 1, &ctx_plain);
1078 let result_indexed = reachable_4d(&nodes, 1, &ctx_indexed);
1079
1080 assert_eq!(
1081 result_plain, result_indexed,
1082 "reachable_4d should produce same results with and without octree"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_astar_with_octree_matches_without() {
1088 let nodes = make_test_nodes();
1089 let index = SpatialIndex::from_nodes(&nodes);
1090 let region = SpatialRegion::Sphere {
1091 center: Vec3::new(0.0, 0.0, 0.0),
1092 radius: 15.0,
1093 };
1094
1095 let ctx_plain = TraversalContext4D {
1096 spatial_region: Some(region),
1097 ..Default::default()
1098 };
1099 let ctx_indexed = TraversalContext4D {
1100 spatial_region: Some(region),
1101 spatial_candidates: Some(index.prefilter(®ion)),
1102 ..Default::default()
1103 };
1104
1105 let result_plain = astar_find_path_4d(&nodes, 1, 3, &ctx_plain);
1106 let result_indexed = astar_find_path_4d(&nodes, 1, 3, &ctx_indexed);
1107
1108 assert_eq!(
1109 result_plain, result_indexed,
1110 "astar should produce same results with and without octree"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_no_spatial_region_no_candidates_still_works() {
1116 let nodes = make_test_nodes();
1117 let ctx = TraversalContext4D::default();
1118
1119 for node in &nodes {
1121 assert!(
1122 node_is_valid(node, &ctx),
1123 "node {} should be valid without spatial constraints",
1124 node.id
1125 );
1126 }
1127 }
1128}