1use sphereql_core::SphericalPoint;
8use sphereql_core::spatial::{
9 CoverageReport, VoronoiCell, cap_exclusivity, cap_intersection_area, cap_solid_angle,
10 estimate_coverage, spherical_voronoi,
11};
12
13use crate::category::CategoryGraph;
14use crate::config::PipelineConfig;
15
16#[derive(Debug, Clone)]
23pub struct SpatialQuality {
24 pub evr: f64,
26
27 pub cap_areas: Vec<f64>,
29
30 pub exclusivities: Vec<f64>,
33
34 pub voronoi_cells: Vec<VoronoiCell>,
36
37 pub pairwise_intersections: Vec<PairIntersection>,
39
40 pub coverage: CoverageReport,
42
43 pub bridge_threshold: f64,
46
47 pub bridge_quality_matrix: Vec<Vec<f64>>,
52}
53
54#[derive(Debug, Clone, Copy)]
59pub struct PairIntersection {
60 pub cat_a: usize,
62 pub cat_b: usize,
64 pub area: f64,
66}
67
68impl SpatialQuality {
69 pub fn compute(centroids: &[SphericalPoint], half_angles: &[f64], evr: f64) -> Self {
75 Self::compute_with_config(centroids, half_angles, evr, &PipelineConfig::default())
76 }
77
78 pub fn compute_with_config(
84 centroids: &[SphericalPoint],
85 half_angles: &[f64],
86 evr: f64,
87 config: &PipelineConfig,
88 ) -> Self {
89 let n = centroids.len();
90 let sc = &config.spatial;
91
92 let cap_areas: Vec<f64> = half_angles.iter().map(|&a| cap_solid_angle(a)).collect();
93
94 let exclusivities: Vec<f64> = (0..n)
95 .map(|i| cap_exclusivity(i, centroids, half_angles, sc.exclusivity_samples))
96 .collect();
97
98 let voronoi_cells = spherical_voronoi(centroids, sc.voronoi_samples);
99
100 let mut pairwise_intersections = Vec::with_capacity(n * (n - 1) / 2);
101 for i in 0..n {
102 for j in (i + 1)..n {
103 let area = cap_intersection_area(
104 ¢roids[i],
105 half_angles[i],
106 ¢roids[j],
107 half_angles[j],
108 );
109 if area > 1e-15 {
110 pairwise_intersections.push(PairIntersection {
111 cat_a: i,
112 cat_b: j,
113 area,
114 });
115 }
116 }
117 }
118
119 let coverage = estimate_coverage(centroids, half_angles, sc.coverage_samples);
120
121 let bridge_threshold = config.bridges.evr_adaptive_threshold(evr);
123
124 Self {
125 evr,
126 cap_areas,
127 exclusivities,
128 voronoi_cells,
129 pairwise_intersections,
130 coverage,
131 bridge_threshold,
132 bridge_quality_matrix: vec![vec![0.0; n]; n],
133 }
134 }
135
136 pub fn set_bridge_quality_matrix(&mut self, graph: &CategoryGraph) {
141 let n = self.exclusivities.len();
142 self.bridge_quality_matrix = vec![vec![0.0; n]; n];
143 for (i, edges) in graph.adjacency.iter().enumerate() {
144 for edge in edges {
145 let j = edge.target;
146 self.bridge_quality_matrix[i][j] =
147 edge.max_bridge_strength * self.territorial_factor(i, j);
148 }
149 }
150 }
151
152 pub fn territorial_factor(&self, cat_a: usize, cat_b: usize) -> f64 {
158 let ea = self.exclusivities.get(cat_a).copied().unwrap_or(1.0);
159 let eb = self.exclusivities.get(cat_b).copied().unwrap_or(1.0);
160 (ea * eb).sqrt().max(0.05)
161 }
162
163 pub fn are_voronoi_neighbors(&self, cat_a: usize, cat_b: usize) -> bool {
165 self.voronoi_cells
166 .get(cat_a)
167 .is_some_and(|cell| cell.neighbor_indices.contains(&cat_b))
168 }
169
170 pub fn voronoi_area(&self, cat: usize) -> f64 {
172 self.voronoi_cells.get(cat).map_or(0.0, |cell| cell.area)
173 }
174
175 pub fn territorial_efficiency(&self, cat: usize, item_count: usize) -> f64 {
177 let area = self.voronoi_area(cat);
178 if area > 1e-15 {
179 item_count as f64 / area
180 } else {
181 0.0
182 }
183 }
184
185 pub fn intersection_area(&self, cat_a: usize, cat_b: usize) -> f64 {
187 let (lo, hi) = if cat_a < cat_b {
188 (cat_a, cat_b)
189 } else {
190 (cat_b, cat_a)
191 };
192 self.pairwise_intersections
193 .iter()
194 .find(|p| p.cat_a == lo && p.cat_b == hi)
195 .map_or(0.0, |p| p.area)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::f64::consts::{FRAC_PI_2, PI};
203
204 fn unit(theta: f64, phi: f64) -> SphericalPoint {
205 SphericalPoint::new_unchecked(1.0, theta, phi)
206 }
207
208 #[test]
209 fn spatial_quality_basic() {
210 let centroids = vec![
211 unit(0.0, FRAC_PI_2),
212 unit(PI, FRAC_PI_2),
213 unit(FRAC_PI_2, FRAC_PI_2),
214 ];
215 let half_angles = vec![0.5, 0.5, 0.5];
216 let sq = SpatialQuality::compute(¢roids, &half_angles, 0.5);
217
218 assert_eq!(sq.cap_areas.len(), 3);
219 assert_eq!(sq.exclusivities.len(), 3);
220 assert_eq!(sq.voronoi_cells.len(), 3);
221 assert!(sq.coverage.coverage_fraction > 0.0);
222 assert!(sq.bridge_threshold > 0.5);
223 }
224
225 #[test]
226 fn bridge_threshold_scales_with_evr() {
227 let centroids = vec![unit(0.0, FRAC_PI_2)];
228 let half_angles = vec![0.5];
229
230 let sq_low = SpatialQuality::compute(¢roids, &half_angles, 0.19);
231 let sq_high = SpatialQuality::compute(¢roids, &half_angles, 0.80);
232
233 assert!(
234 sq_low.bridge_threshold > sq_high.bridge_threshold,
235 "low EVR should have stricter threshold: {} vs {}",
236 sq_low.bridge_threshold,
237 sq_high.bridge_threshold
238 );
239 }
240
241 #[test]
242 fn territorial_factor_range() {
243 let centroids = vec![unit(0.0, FRAC_PI_2), unit(PI, FRAC_PI_2)];
244 let half_angles = vec![0.5, 0.5];
245 let sq = SpatialQuality::compute(¢roids, &half_angles, 0.5);
246
247 let tf = sq.territorial_factor(0, 1);
248 assert!(
249 tf > 0.0 && tf <= 1.0,
250 "territorial factor out of range: {tf}"
251 );
252 }
253
254 #[test]
255 fn voronoi_neighbors_detected() {
256 let centroids = vec![
257 unit(0.0, FRAC_PI_2),
258 unit(0.5, FRAC_PI_2),
259 unit(PI, FRAC_PI_2),
260 ];
261 let half_angles = vec![0.3, 0.3, 0.3];
262 let sq = SpatialQuality::compute(¢roids, &half_angles, 0.5);
263
264 assert!(
265 sq.are_voronoi_neighbors(0, 1),
266 "close centroids should be Voronoi neighbors"
267 );
268 }
269
270 #[test]
271 fn exclusivities_bounded() {
272 let centroids = vec![unit(0.0, FRAC_PI_2), unit(PI, FRAC_PI_2)];
273 let half_angles = vec![0.3, 0.3];
274 let sq = SpatialQuality::compute(¢roids, &half_angles, 0.5);
275
276 for &e in &sq.exclusivities {
277 assert!((0.0..=1.0).contains(&e), "exclusivity out of range: {e}");
278 }
279 }
280}