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