Skip to content

Commit 19c9d20

Browse files
committed
Atlas API fix, and accuracy output (optional)
1 parent 136c48e commit 19c9d20

5 files changed

Lines changed: 110 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/analyzer.rs

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ use std::collections::HashSet;
44
use crate::geo::{haversine_batch, haversine_distance, EARTH_RADIUS_KM};
55
use crate::model::{Airport, Disc, OutputRecord};
66

7+
/// Result of geolocating a single MIS cluster.
8+
pub struct GeolocationResult {
9+
pub airport: Airport,
10+
/// Max pairwise distance (km) between surviving candidate cities
11+
pub candidate_diameter: f32,
12+
/// Number of discs that successfully narrowed the candidate set
13+
pub num_constraints: u32,
14+
}
15+
716
pub struct AnycastAnalyzer<'a> {
817
/// Alpha parameter (distance/population tuner)
918
alpha: f32,
@@ -15,6 +24,8 @@ pub struct AnycastAnalyzer<'a> {
1524
all_discs: Vec<Disc>,
1625
/// Boolean, returns only geolocation for anycast targets if true
1726
anycast_only: bool,
27+
/// Whether to compute accuracy metrics
28+
accuracy: bool,
1829
}
1930

2031
impl<'a> AnycastAnalyzer<'a> {
@@ -26,6 +37,7 @@ impl<'a> AnycastAnalyzer<'a> {
2637
alpha: f32,
2738
pop_ratio: f32,
2839
anycast_only: bool,
40+
accuracy: bool,
2941
) -> Self {
3042
// Sort discs from lowest to highest
3143
discs.sort_unstable_by(|a, b| a.radius.partial_cmp(&b.radius).unwrap());
@@ -35,6 +47,7 @@ impl<'a> AnycastAnalyzer<'a> {
3547
airport_tree,
3648
all_discs: discs,
3749
anycast_only,
50+
accuracy,
3851
}
3952
}
4053

@@ -67,23 +80,25 @@ impl<'a> AnycastAnalyzer<'a> {
6780
let geolocation_result = self.geolocation(&cluster);
6881

6982
// Return the geolocation result (in expected output format)
70-
if let Some(best_airport) = geolocation_result {
71-
if chosen_airports.contains(&best_airport.iata) {
83+
if let Some(geo) = geolocation_result {
84+
if chosen_airports.contains(&geo.airport.iata) {
7285
continue;
7386
}
74-
chosen_airports.insert(best_airport.iata.clone());
87+
chosen_airports.insert(geo.airport.iata.clone());
7588

7689
results.push(OutputRecord {
7790
target: target_ip.clone(),
7891
vp: disc_in_mis.hostname.clone(),
7992
vp_lat: disc_in_mis.lat.to_degrees(),
8093
vp_lon: disc_in_mis.lon.to_degrees(),
8194
radius: disc_in_mis.radius,
82-
pop_iata: best_airport.iata.clone(),
83-
pop_lat: best_airport.lat,
84-
pop_lon: best_airport.lon,
85-
pop_city: best_airport.city.clone(),
86-
pop_cc: best_airport.country_code.clone(),
95+
pop_iata: geo.airport.iata.clone(),
96+
pop_lat: geo.airport.lat,
97+
pop_lon: geo.airport.lon,
98+
pop_city: geo.airport.city.clone(),
99+
pop_cc: geo.airport.country_code.clone(),
100+
candidate_diameter: self.accuracy.then_some(geo.candidate_diameter),
101+
num_constraints: self.accuracy.then_some(geo.num_constraints),
87102
});
88103
} else {
89104
results.push(OutputRecord {
@@ -97,6 +112,8 @@ impl<'a> AnycastAnalyzer<'a> {
97112
pop_lon: disc_in_mis.lon.to_degrees(),
98113
pop_city: "N/A".to_string(),
99114
pop_cc: "N/A".to_string(),
115+
candidate_diameter: self.accuracy.then_some(0.0),
116+
num_constraints: self.accuracy.then_some(0),
100117
});
101118
}
102119
}
@@ -205,7 +222,7 @@ impl<'a> AnycastAnalyzer<'a> {
205222
}
206223

207224
/// Geolocate the best location for an MIS cluster of discs
208-
fn geolocation(&self, cluster: &[&Disc]) -> Option<Airport> {
225+
fn geolocation(&self, cluster: &[&Disc]) -> Option<GeolocationResult> {
209226
// Get the MIS for this cluster (smallest circle)
210227
let smallest = cluster
211228
.iter()
@@ -257,15 +274,19 @@ impl<'a> AnycastAnalyzer<'a> {
257274
let mut batch_dists = vec![0.0f32; n_apts];
258275

259276
// Progressively intersect (reducing bbox size)
277+
// Start at 1: the bbox pre-filter already applies the smallest disc's constraint
278+
let mut num_constraints: u32 = 1;
260279
for disc in &sorted_cluster {
261280
prev_alive.copy_from_slice(&alive);
262281
// Calculate distance between current discs and all eligible locations
263282
haversine_batch(disc.lat, disc.lon, &apt_lats, &apt_lons, &mut batch_dists);
264283

265284
// Eliminate cities outside of this disc
285+
let mut changed = false;
266286
for i in 0..n_apts {
267287
if alive[i] && batch_dists[i] > disc.radius {
268288
alive[i] = false;
289+
changed = true;
269290
}
270291
}
271292

@@ -274,6 +295,11 @@ impl<'a> AnycastAnalyzer<'a> {
274295
alive.copy_from_slice(&prev_alive);
275296
break;
276297
}
298+
299+
// Count discs that actually narrowed the candidate set
300+
if changed {
301+
num_constraints += 1;
302+
}
277303
}
278304

279305
// Get all eligible locations that survived
@@ -302,6 +328,27 @@ impl<'a> AnycastAnalyzer<'a> {
302328
let total_pop: f32 = candidates.iter().map(|(a, _)| a.pop as f32).sum();
303329
let total_dist: f32 = candidates.iter().map(|(_, d)| *d).sum();
304330

331+
// Compute candidate diameter: max pairwise distance between surviving candidates
332+
let candidate_diameter = if self.accuracy && candidates.len() > 1 {
333+
let mut max_dist: f32 = 0.0;
334+
for i in 0..candidates.len() {
335+
for j in (i + 1)..candidates.len() {
336+
let d = haversine_distance(
337+
candidates[i].0.lat_rad,
338+
candidates[i].0.lon_rad,
339+
candidates[j].0.lat_rad,
340+
candidates[j].0.lon_rad,
341+
);
342+
if d > max_dist {
343+
max_dist = d;
344+
}
345+
}
346+
}
347+
max_dist
348+
} else {
349+
0.0
350+
};
351+
305352
// Return the city with the highest score
306353
candidates
307354
.into_iter()
@@ -316,6 +363,10 @@ impl<'a> AnycastAnalyzer<'a> {
316363

317364
score1.partial_cmp(&score2).unwrap_or(std::cmp::Ordering::Equal)
318365
})
319-
.map(|(a, _)| a.clone())
366+
.map(|(a, _)| GeolocationResult {
367+
airport: a.clone(),
368+
candidate_diameter,
369+
num_constraints,
370+
})
320371
}
321372
}

src/atlas.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,25 @@ use std::collections::HashMap;
55

66
use crate::geo::{FIBER_RI, SPEED_OF_LIGHT};
77

8+
/// Deserialize a value that may be a number or a numeric string into Option<f64>.
9+
/// The RIPE Atlas API inconsistently returns some fields as strings.
10+
fn deserialize_f64_or_string<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
11+
where
12+
D: serde::Deserializer<'de>,
13+
{
14+
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
15+
match value {
16+
None => Ok(None),
17+
Some(serde_json::Value::Number(n)) => Ok(n.as_f64()),
18+
Some(serde_json::Value::String(s)) => Ok(s.parse::<f64>().ok()),
19+
_ => Ok(None),
20+
}
21+
}
22+
823
#[derive(Deserialize)]
924
struct AtlasResult {
1025
dst_addr: Option<String>,
11-
#[serde(default)]
26+
#[serde(default, deserialize_with = "deserialize_f64_or_string")]
1227
min: Option<f64>,
1328
prb_id: u32,
1429
#[serde(rename = "type")]

src/main.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ struct Args {
8585
help = "Only output anycast geolocations (skip unicast)"
8686
)]
8787
anycast: bool,
88+
89+
#[arg(
90+
long,
91+
default_value_t = false,
92+
help = "Include accuracy metrics: candidate_diameter (km) and num_constraints"
93+
)]
94+
accuracy: bool,
8895
}
8996

9097
fn main() -> Result<()> {
@@ -218,6 +225,7 @@ fn main() -> Result<()> {
218225
args.alpha,
219226
args.pop_ratio,
220227
args.anycast,
228+
args.accuracy,
221229
);
222230
analyzer.analyze()
223231
})
@@ -296,6 +304,26 @@ fn main() -> Result<()> {
296304
.into(),
297305
])?;
298306

307+
// Append accuracy columns if --accuracy flag is set
308+
if args.accuracy {
309+
let diameter_col = Series::new(
310+
"candidate_diameter".into(),
311+
results
312+
.iter()
313+
.map(|r| r.candidate_diameter.unwrap_or(0.0))
314+
.collect::<Vec<f32>>(),
315+
);
316+
let constraints_col = Series::new(
317+
"num_constraints".into(),
318+
results
319+
.iter()
320+
.map(|r| r.num_constraints.unwrap_or(0))
321+
.collect::<Vec<u32>>(),
322+
);
323+
output_df.with_column(diameter_col.into())?;
324+
output_df.with_column(constraints_col.into())?;
325+
}
326+
299327
let mut file = File::create(output_path)?;
300328
CsvWriter::new(&mut file)
301329
.with_separator(b'\t')

src/model.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ pub struct OutputRecord {
4242
pub pop_lon: f32,
4343
pub pop_city: String,
4444
pub pop_cc: String,
45+
/// Max pairwise distance (km) between surviving candidate cities (Option 2)
46+
pub candidate_diameter: Option<f32>,
47+
/// Number of discs that successfully narrowed the candidate set (Option 4)
48+
pub num_constraints: Option<u32>,
4549
}

0 commit comments

Comments
 (0)