diff --git a/Cargo.lock b/Cargo.lock index 43726fc94..da0281013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2121,6 +2121,19 @@ dependencies = [ "volatile-register", ] +[[package]] +name = "coverage-map" +version = "0.1.0" +dependencies = [ + "chrono", + "h3o", + "helium-crypto", + "helium-proto", + "hex-assignments", + "hextree", + "uuid", +] + [[package]] name = "cpufeatures" version = "0.2.5" @@ -4602,6 +4615,7 @@ dependencies = [ "chrono", "clap 4.4.8", "config", + "coverage-map", "custom-tracing", "db-store", "file-store", diff --git a/Cargo.toml b/Cargo.toml index a858438fa..46474b62e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ debug = true [workspace] members = [ "boost_manager", + "coverage_map", "custom_tracing", "db_store", "denylist", diff --git a/coverage_map/Cargo.toml b/coverage_map/Cargo.toml new file mode 100644 index 000000000..d08ec321e --- /dev/null +++ b/coverage_map/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "coverage-map" +version = "0.1.0" +authors.workspace = true +license.workspace = true +edition.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +chrono = { workspace = true } +h3o = { workspace = true } +helium-crypto = { workspace = true } +helium-proto = { workspace = true } +hex-assignments = { path = "../hex_assignments" } +hextree = { workspace = true } +uuid = { workspace = true } diff --git a/coverage_map/src/indoor.rs b/coverage_map/src/indoor.rs new file mode 100644 index 000000000..46442d929 --- /dev/null +++ b/coverage_map/src/indoor.rs @@ -0,0 +1,297 @@ +use std::{ + cmp::Ordering, + collections::{hash_map::Entry, BTreeMap, BinaryHeap, HashMap}, +}; + +use chrono::{DateTime, Utc}; +use helium_crypto::PublicKeyBinary; +use hex_assignments::assignment::HexAssignments; +use hextree::Cell; + +use crate::{BoostedHexMap, CoverageObject, RankedCoverage, SignalLevel, UnrankedCoverage}; + +pub type IndoorCellTree = HashMap>>; + +#[derive(Eq, Debug, Clone)] +pub struct IndoorCoverageLevel { + hotspot_key: PublicKeyBinary, + cbsd_id: Option, + seniority_timestamp: DateTime, + signal_level: SignalLevel, + assignments: HexAssignments, +} + +impl PartialEq for IndoorCoverageLevel { + fn eq(&self, other: &Self) -> bool { + self.seniority_timestamp == other.seniority_timestamp + } +} + +impl PartialOrd for IndoorCoverageLevel { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for IndoorCoverageLevel { + fn cmp(&self, other: &Self) -> Ordering { + self.seniority_timestamp.cmp(&other.seniority_timestamp) + } +} + +pub fn insert_indoor_coverage_object(indoor: &mut IndoorCellTree, coverage_object: CoverageObject) { + for hex_coverage in coverage_object.coverage.into_iter() { + insert_indoor_coverage( + indoor, + &coverage_object.hotspot_key, + &coverage_object.cbsd_id, + coverage_object.seniority_timestamp, + hex_coverage, + ); + } +} + +pub fn insert_indoor_coverage( + indoor: &mut IndoorCellTree, + hotspot: &PublicKeyBinary, + cbsd_id: &Option, + seniority_timestamp: DateTime, + hex_coverage: UnrankedCoverage, +) { + indoor + .entry(hex_coverage.location) + .or_default() + .entry(hex_coverage.signal_level) + .or_default() + .push(IndoorCoverageLevel { + hotspot_key: hotspot.clone(), + cbsd_id: cbsd_id.clone(), + seniority_timestamp, + signal_level: hex_coverage.signal_level, + assignments: hex_coverage.assignments, + }) +} + +pub fn clone_indoor_coverage_into_submap( + submap: &mut IndoorCellTree, + from: &IndoorCellTree, + coverage_obj: &CoverageObject, +) { + for coverage in &coverage_obj.coverage { + if let Entry::Vacant(e) = submap.entry(coverage.location) { + if let Some(old_coverage_data) = from.get(&coverage.location) { + e.insert(old_coverage_data.clone()); + } + } + } +} + +pub fn into_indoor_coverage_map( + indoor: IndoorCellTree, + boosted_hexes: &impl BoostedHexMap, + epoch_start: DateTime, +) -> impl Iterator + '_ { + indoor.into_iter().flat_map(move |(hex, radios)| { + let boosted = boosted_hexes.get_current_multiplier(hex, epoch_start); + radios + .into_values() + .flat_map(move |radios| radios.into_sorted_vec().into_iter()) + .enumerate() + .map(move |(rank, cov)| RankedCoverage { + hex, + rank: rank + 1, + hotspot_key: cov.hotspot_key, + cbsd_id: cov.cbsd_id, + assignments: cov.assignments, + boosted, + signal_level: cov.signal_level, + }) + }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::*; + use chrono::NaiveDate; + use hex_assignments::Assignment; + use hextree::Cell; + + #[test] + fn ensure_max_signal_level_selected() { + let mut indoor_coverage = IndoorCellTree::default(); + for cov_obj in vec![ + indoor_cbrs_coverage("1", SignalLevel::None), + indoor_cbrs_coverage("2", SignalLevel::Low), + indoor_cbrs_coverage("3", SignalLevel::High), + indoor_cbrs_coverage("4", SignalLevel::Low), + indoor_cbrs_coverage("5", SignalLevel::None), + ] + .into_iter() + { + insert_indoor_coverage_object(&mut indoor_coverage, cov_obj); + } + let ranked: HashMap<_, _> = + into_indoor_coverage_map(indoor_coverage, &NoBoostedHexes, Utc::now()) + .map(|x| (x.cbsd_id.clone().unwrap(), x)) + .collect(); + assert_eq!(ranked.get("3").unwrap().rank, 1); + assert!({ + let rank = ranked.get("2").unwrap().rank; + rank == 2 || rank == 3 + }); + assert!({ + let rank = ranked.get("4").unwrap().rank; + rank == 2 || rank == 3 + }); + assert!({ + let rank = ranked.get("1").unwrap().rank; + rank == 4 || rank == 5 + }); + assert!({ + let rank = ranked.get("5").unwrap().rank; + rank == 4 || rank == 5 + }); + } + + #[test] + fn ensure_oldest_radio_selected() { + let mut indoor_coverage = IndoorCellTree::default(); + for cov_obj in vec![ + indoor_cbrs_coverage_with_date("1", SignalLevel::High, date(1980, 1, 1)), + indoor_cbrs_coverage_with_date("2", SignalLevel::High, date(1970, 1, 5)), + indoor_cbrs_coverage_with_date("3", SignalLevel::High, date(1990, 2, 2)), + indoor_cbrs_coverage_with_date("4", SignalLevel::High, date(1970, 1, 4)), + indoor_cbrs_coverage_with_date("5", SignalLevel::High, date(1975, 3, 3)), + indoor_cbrs_coverage_with_date("6", SignalLevel::High, date(1970, 1, 3)), + indoor_cbrs_coverage_with_date("7", SignalLevel::High, date(1974, 2, 2)), + indoor_cbrs_coverage_with_date("8", SignalLevel::High, date(1970, 1, 2)), + indoor_cbrs_coverage_with_date("9", SignalLevel::High, date(1976, 5, 2)), + indoor_cbrs_coverage_with_date("10", SignalLevel::High, date(1970, 1, 1)), + ] + .into_iter() + { + insert_indoor_coverage_object(&mut indoor_coverage, cov_obj); + } + let ranked: HashMap<_, _> = + into_indoor_coverage_map(indoor_coverage, &NoBoostedHexes, Utc::now()) + .map(|x| (x.cbsd_id.clone().unwrap(), x)) + .collect(); + assert_eq!(ranked.get("1").unwrap().rank, 9); + assert_eq!(ranked.get("2").unwrap().rank, 5); + assert_eq!(ranked.get("3").unwrap().rank, 10); + assert_eq!(ranked.get("4").unwrap().rank, 4); + assert_eq!(ranked.get("5").unwrap().rank, 7); + assert_eq!(ranked.get("6").unwrap().rank, 3); + assert_eq!(ranked.get("7").unwrap().rank, 6); + assert_eq!(ranked.get("8").unwrap().rank, 2); + assert_eq!(ranked.get("9").unwrap().rank, 8); + assert_eq!(ranked.get("10").unwrap().rank, 1); + } + + fn hex_assignments_mock() -> HexAssignments { + HexAssignments { + footfall: Assignment::A, + urbanized: Assignment::A, + landtype: Assignment::A, + } + } + + fn date(year: i32, month: u32, day: u32) -> DateTime { + NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + } + + #[test] + fn single_radio() { + let mut indoor_coverage = IndoorCellTree::default(); + + insert_indoor_coverage_object( + &mut indoor_coverage, + indoor_cbrs_coverage_with_loc( + "1", + Cell::from_raw(0x8c2681a3064d9ff).unwrap(), + date(2022, 2, 2), + ), + ); + insert_indoor_coverage_object( + &mut indoor_coverage, + indoor_cbrs_coverage_with_loc( + "1", + Cell::from_raw(0x8c2681a3064dbff).unwrap(), + date(2022, 2, 2), + ), + ); + + let coverage = into_indoor_coverage_map(indoor_coverage, &NoBoostedHexes, Utc::now()) + .collect::>(); + // Both coverages should be ranked 1 + assert_eq!(coverage[0].rank, 1); + assert_eq!(coverage[1].rank, 1); + } + + fn indoor_cbrs_coverage(cbsd_id: &str, signal_level: SignalLevel) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: true, + hotspot_key: owner, + seniority_timestamp: Utc::now(), + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(0x8a1fb46622dffff).expect("valid h3 cell"), + signal_power: 0, + signal_level, + assignments: hex_assignments_mock(), + }], + } + } + + fn indoor_cbrs_coverage_with_date( + cbsd_id: &str, + signal_level: SignalLevel, + seniority_timestamp: DateTime, + ) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: true, + hotspot_key: owner, + seniority_timestamp, + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(0x8a1fb46622dffff).expect("valid h3 cell"), + signal_power: 0, + signal_level, + assignments: hex_assignments_mock(), + }], + } + } + + fn indoor_cbrs_coverage_with_loc( + cbsd_id: &str, + location: Cell, + seniority_timestamp: DateTime, + ) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: true, + hotspot_key: owner, + seniority_timestamp, + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location, + signal_power: 0, + signal_level: SignalLevel::High, + assignments: hex_assignments_mock(), + }], + } + } +} diff --git a/coverage_map/src/lib.rs b/coverage_map/src/lib.rs new file mode 100644 index 000000000..3a565a1be --- /dev/null +++ b/coverage_map/src/lib.rs @@ -0,0 +1,417 @@ +use std::{collections::HashMap, num::NonZeroU32}; + +use chrono::{DateTime, Utc}; +use helium_crypto::PublicKeyBinary; +use hex_assignments::assignment::HexAssignments; +use hextree::Cell; + +mod indoor; +mod outdoor; + +use indoor::*; +use outdoor::*; + +/// Data structure for keeping track of the ranking the coverage in each hex cell for indoor +/// and outdoor CBRS and WiFi radios. +#[derive(Clone, Default, Debug)] +pub struct CoverageMapBuilder { + indoor_cbrs: IndoorCellTree, + indoor_wifi: IndoorCellTree, + outdoor_cbrs: OutdoorCellTree, + outdoor_wifi: OutdoorCellTree, +} + +impl CoverageMapBuilder { + /// Inserts a new coverage object into the builder. + pub fn insert_coverage_object(&mut self, coverage_obj: CoverageObject) { + match (coverage_obj.indoor, coverage_obj.cbsd_id.is_some()) { + (true, true) => insert_indoor_coverage_object(&mut self.indoor_cbrs, coverage_obj), + (true, false) => insert_indoor_coverage_object(&mut self.indoor_wifi, coverage_obj), + (false, true) => insert_outdoor_coverage_object(&mut self.outdoor_cbrs, coverage_obj), + (false, false) => insert_outdoor_coverage_object(&mut self.outdoor_wifi, coverage_obj), + } + } + + /// Creates a submap from the current `CoverageMapBuilder` and the provided `coverage_objs`. + /// + /// A submap only contains the hexes that exist in the provided `coverage_objs` arguments. This + /// allows for one to determine the potential ranking of new coverage objects without having + /// to clone the entire CoverageMapBuilder. + pub fn submap(&self, coverage_objs: Vec) -> Self { + // A different way to implement this function would be to insert all of the coverage_objs into + // the submap, and then reconstruct the coverage objs from only the relevant hexes and then + // insert them into the new coverage object builder. + let mut new_submap = Self::default(); + for coverage_obj in coverage_objs { + // Clone each of the hexes in the current coverage from the old map into the new submap: + match (coverage_obj.indoor, coverage_obj.cbsd_id.is_some()) { + (true, true) => clone_indoor_coverage_into_submap( + &mut new_submap.indoor_cbrs, + &self.indoor_cbrs, + &coverage_obj, + ), + (true, false) => clone_indoor_coverage_into_submap( + &mut new_submap.indoor_wifi, + &self.indoor_wifi, + &coverage_obj, + ), + (false, true) => clone_outdoor_coverage_into_submap( + &mut new_submap.outdoor_cbrs, + &self.outdoor_cbrs, + &coverage_obj, + ), + (false, false) => clone_outdoor_coverage_into_submap( + &mut new_submap.outdoor_wifi, + &self.outdoor_wifi, + &coverage_obj, + ), + } + // Now that we are sure that each of the hexes in this coverage object are in the new + // submap, we can insert the new coverage obj. + new_submap.insert_coverage_object(coverage_obj); + } + new_submap + } + + /// Constructs a [CoverageMap] from the current `CoverageMapBuilder` + pub fn build( + self, + boosted_hexes: &impl BoostedHexMap, + epoch_start: DateTime, + ) -> CoverageMap { + let mut wifi_hotspots = HashMap::<_, Vec>::new(); + let mut cbrs_radios = HashMap::<_, Vec>::new(); + for coverage in into_indoor_coverage_map(self.indoor_cbrs, boosted_hexes, epoch_start) + .chain(into_indoor_coverage_map( + self.indoor_wifi, + boosted_hexes, + epoch_start, + )) + .chain(into_outdoor_coverage_map( + self.outdoor_cbrs, + boosted_hexes, + epoch_start, + )) + .chain(into_outdoor_coverage_map( + self.outdoor_wifi, + boosted_hexes, + epoch_start, + )) + { + if let Some(ref cbsd_id) = coverage.cbsd_id { + cbrs_radios + .entry(cbsd_id.clone()) + .or_default() + .push(coverage); + } else { + wifi_hotspots + .entry(coverage.hotspot_key.clone()) + .or_default() + .push(coverage); + } + } + CoverageMap { + wifi_hotspots, + cbrs_radios, + } + } +} + +/// Data structure from mapping radios to their ranked hex coverage +#[derive(Clone, Default, Debug)] +pub struct CoverageMap { + wifi_hotspots: HashMap>, + cbrs_radios: HashMap>, +} + +impl CoverageMap { + /// Returns the hexes covered by the WiFi hotspot. The returned slice can be empty, indicating that + /// the hotspot did not meet the criteria to be ranked in any hex. + pub fn get_wifi_coverage(&self, wifi_hotspot: &PublicKeyBinary) -> &[RankedCoverage] { + self.wifi_hotspots + .get(wifi_hotspot) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + /// Returns the hexes covered by the CBRS radio. The returned slice can be empty, indicating that + /// the radio did not meet the criteria to be ranked in any hex. + pub fn get_cbrs_coverage(&self, cbrs_radio: &str) -> &[RankedCoverage] { + self.cbrs_radios + .get(cbrs_radio) + .map(Vec::as_slice) + .unwrap_or(&[]) + } +} + +/// Coverage data given as input to the [CoverageMapBuilder] +#[derive(Clone, Debug)] +pub struct CoverageObject { + pub indoor: bool, + pub hotspot_key: PublicKeyBinary, + pub cbsd_id: Option, + pub seniority_timestamp: DateTime, + pub coverage: Vec, +} + +/// Unranked hex coverage data given as input to the [CoverageMapBuilder] +#[derive(Clone, Debug)] +pub struct UnrankedCoverage { + pub location: Cell, + pub signal_power: i32, + pub signal_level: SignalLevel, + pub assignments: HexAssignments, +} + +/// Ranked hex coverage given as output from the [CoverageMap] +#[derive(Clone, Debug)] +pub struct RankedCoverage { + pub hex: Cell, + pub rank: usize, + pub hotspot_key: PublicKeyBinary, + pub cbsd_id: Option, + pub assignments: HexAssignments, + pub boosted: Option, + pub signal_level: SignalLevel, +} + +#[derive(Copy, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)] +pub enum SignalLevel { + High, + Medium, + Low, + None, +} + +pub trait BoostedHexMap { + fn get_current_multiplier(&self, cell: Cell, ts: DateTime) -> Option; +} + +#[cfg(test)] +pub(crate) struct NoBoostedHexes; + +#[cfg(test)] +impl BoostedHexMap for NoBoostedHexes { + fn get_current_multiplier(&self, _cell: Cell, _ts: DateTime) -> Option { + None + } +} + +#[cfg(test)] +mod test { + use super::*; + use hex_assignments::Assignment; + + #[test] + fn test_indoor_cbrs_submap() { + let mut coverage_map_builder = CoverageMapBuilder::default(); + coverage_map_builder.insert_coverage_object(indoor_cbrs_coverage( + "1", + 0x8a1fb46622dffff, + SignalLevel::High, + )); + coverage_map_builder.insert_coverage_object(indoor_cbrs_coverage( + "2", + 0x8c2681a3064d9ff, + SignalLevel::Low, + )); + let submap_builder = coverage_map_builder.submap(vec![indoor_cbrs_coverage( + "3", + 0x8c2681a3064d9ff, + SignalLevel::High, + )]); + let submap = submap_builder.build(&NoBoostedHexes, Utc::now()); + let cov_1 = submap.get_cbrs_coverage("1"); + assert_eq!(cov_1.len(), 0); + let cov_2 = submap.get_cbrs_coverage("2"); + assert_eq!(cov_2.len(), 1); + assert_eq!(cov_2[0].rank, 2); + let cov_3 = submap.get_cbrs_coverage("3"); + assert_eq!(cov_3.len(), 1); + assert_eq!(cov_3[0].rank, 1); + } + + #[test] + fn test_indoor_wifi_submap() { + let mut coverage_map_builder = CoverageMapBuilder::default(); + coverage_map_builder.insert_coverage_object(indoor_wifi_coverage( + "11xtYwQYnvkFYnJ9iZ8kmnetYKwhdi87Mcr36e1pVLrhBMPLjV9", + 0x8a1fb46622dffff, + SignalLevel::High, + )); + coverage_map_builder.insert_coverage_object(indoor_wifi_coverage( + "11PGVtgW9aM9ynfvns5USUsynYQ7EsMpxVqWuDKqFogKQX7etkR", + 0x8c2681a3064d9ff, + SignalLevel::Low, + )); + let submap_builder = coverage_map_builder.submap(vec![indoor_wifi_coverage( + "11ibmJmQXTL6qMh4cq9pJ7tUtrpafWaVjjT6qhY7CNvjyvY9g1", + 0x8c2681a3064d9ff, + SignalLevel::High, + )]); + let submap = submap_builder.build(&NoBoostedHexes, Utc::now()); + let cov_1 = submap.get_wifi_coverage( + &"11xtYwQYnvkFYnJ9iZ8kmnetYKwhdi87Mcr36e1pVLrhBMPLjV9" + .parse() + .unwrap(), + ); + assert_eq!(cov_1.len(), 0); + let cov_2 = submap.get_wifi_coverage( + &"11PGVtgW9aM9ynfvns5USUsynYQ7EsMpxVqWuDKqFogKQX7etkR" + .parse() + .unwrap(), + ); + assert_eq!(cov_2.len(), 1); + assert_eq!(cov_2[0].rank, 2); + let cov_3 = submap.get_wifi_coverage( + &"11ibmJmQXTL6qMh4cq9pJ7tUtrpafWaVjjT6qhY7CNvjyvY9g1" + .parse() + .unwrap(), + ); + assert_eq!(cov_3.len(), 1); + assert_eq!(cov_3[0].rank, 1); + } + + #[test] + fn test_outdoor_cbrs_submap() { + let mut coverage_map_builder = CoverageMapBuilder::default(); + coverage_map_builder.insert_coverage_object(outdoor_cbrs_coverage( + "1", + 0x8a1fb46622dffff, + 3, + )); + coverage_map_builder.insert_coverage_object(outdoor_cbrs_coverage( + "2", + 0x8c2681a3064d9ff, + 1, + )); + let submap_builder = + coverage_map_builder.submap(vec![outdoor_cbrs_coverage("3", 0x8c2681a3064d9ff, 2)]); + let submap = submap_builder.build(&NoBoostedHexes, Utc::now()); + let cov_1 = submap.get_cbrs_coverage("1"); + assert_eq!(cov_1.len(), 0); + let cov_2 = submap.get_cbrs_coverage("2"); + assert_eq!(cov_2.len(), 1); + assert_eq!(cov_2[0].rank, 2); + let cov_3 = submap.get_cbrs_coverage("3"); + assert_eq!(cov_3.len(), 1); + assert_eq!(cov_3[0].rank, 1); + } + + #[test] + fn test_outdoor_wifi_submap() { + let mut coverage_map_builder = CoverageMapBuilder::default(); + coverage_map_builder.insert_coverage_object(outdoor_wifi_coverage( + "11xtYwQYnvkFYnJ9iZ8kmnetYKwhdi87Mcr36e1pVLrhBMPLjV9", + 0x8a1fb46622dffff, + 3, + )); + coverage_map_builder.insert_coverage_object(outdoor_wifi_coverage( + "11PGVtgW9aM9ynfvns5USUsynYQ7EsMpxVqWuDKqFogKQX7etkR", + 0x8c2681a3064d9ff, + 1, + )); + let submap_builder = coverage_map_builder.submap(vec![outdoor_wifi_coverage( + "11ibmJmQXTL6qMh4cq9pJ7tUtrpafWaVjjT6qhY7CNvjyvY9g1", + 0x8c2681a3064d9ff, + 2, + )]); + let submap = submap_builder.build(&NoBoostedHexes, Utc::now()); + let cov_1 = submap.get_wifi_coverage( + &"11xtYwQYnvkFYnJ9iZ8kmnetYKwhdi87Mcr36e1pVLrhBMPLjV9" + .parse() + .unwrap(), + ); + assert_eq!(cov_1.len(), 0); + let cov_2 = submap.get_wifi_coverage( + &"11PGVtgW9aM9ynfvns5USUsynYQ7EsMpxVqWuDKqFogKQX7etkR" + .parse() + .unwrap(), + ); + assert_eq!(cov_2.len(), 1); + assert_eq!(cov_2[0].rank, 2); + let cov_3 = submap.get_wifi_coverage( + &"11ibmJmQXTL6qMh4cq9pJ7tUtrpafWaVjjT6qhY7CNvjyvY9g1" + .parse() + .unwrap(), + ); + assert_eq!(cov_3.len(), 1); + assert_eq!(cov_3[0].rank, 1); + } + + fn hex_assignments_mock() -> HexAssignments { + HexAssignments { + footfall: Assignment::A, + urbanized: Assignment::A, + landtype: Assignment::A, + } + } + + fn indoor_cbrs_coverage(cbsd_id: &str, hex: u64, signal_level: SignalLevel) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: true, + hotspot_key: owner, + seniority_timestamp: Utc::now(), + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(hex).expect("valid h3 cell"), + signal_power: 0, + signal_level, + assignments: hex_assignments_mock(), + }], + } + } + + fn indoor_wifi_coverage(owner: &str, hex: u64, signal_level: SignalLevel) -> CoverageObject { + let owner: PublicKeyBinary = owner.parse().expect("failed owner parse"); + CoverageObject { + indoor: true, + hotspot_key: owner, + seniority_timestamp: Utc::now(), + cbsd_id: None, + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(hex).expect("valid h3 cell"), + signal_power: 0, + signal_level, + assignments: hex_assignments_mock(), + }], + } + } + + fn outdoor_cbrs_coverage(cbsd_id: &str, hex: u64, signal_power: i32) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: false, + hotspot_key: owner, + seniority_timestamp: Utc::now(), + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(hex).expect("valid h3 cell"), + signal_power, + signal_level: SignalLevel::None, + assignments: hex_assignments_mock(), + }], + } + } + + fn outdoor_wifi_coverage(owner: &str, hex: u64, signal_power: i32) -> CoverageObject { + let owner: PublicKeyBinary = owner.parse().expect("failed owner parse"); + CoverageObject { + indoor: false, + hotspot_key: owner, + seniority_timestamp: Utc::now(), + cbsd_id: None, + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(hex).expect("valid h3 cell"), + signal_power, + signal_level: SignalLevel::None, + assignments: hex_assignments_mock(), + }], + } + } +} diff --git a/coverage_map/src/outdoor.rs b/coverage_map/src/outdoor.rs new file mode 100644 index 000000000..8105cf4c4 --- /dev/null +++ b/coverage_map/src/outdoor.rs @@ -0,0 +1,190 @@ +use std::{ + cmp::Ordering, + collections::{hash_map::Entry, BinaryHeap, HashMap}, +}; + +use chrono::{DateTime, Utc}; +use helium_crypto::PublicKeyBinary; +use hex_assignments::assignment::HexAssignments; +use hextree::Cell; + +use crate::{BoostedHexMap, CoverageObject, RankedCoverage, SignalLevel, UnrankedCoverage}; + +/// Data structure for storing outdoor radios ranked by their coverage level +pub type OutdoorCellTree = HashMap>; + +#[derive(Eq, Debug, Clone)] +pub struct OutdoorCoverageLevel { + hotspot_key: PublicKeyBinary, + cbsd_id: Option, + seniority_timestamp: DateTime, + signal_power: i32, + signal_level: SignalLevel, + assignments: HexAssignments, +} + +impl PartialEq for OutdoorCoverageLevel { + fn eq(&self, other: &Self) -> bool { + self.signal_power == other.signal_power + && self.seniority_timestamp == other.seniority_timestamp + } +} + +impl PartialOrd for OutdoorCoverageLevel { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for OutdoorCoverageLevel { + fn cmp(&self, other: &Self) -> Ordering { + self.signal_power + .cmp(&other.signal_power) + .reverse() + .then_with(|| self.seniority_timestamp.cmp(&other.seniority_timestamp)) + } +} + +pub fn insert_outdoor_coverage_object( + outdoor: &mut OutdoorCellTree, + coverage_object: CoverageObject, +) { + for hex_coverage in coverage_object.coverage.into_iter() { + insert_outdoor_coverage( + outdoor, + &coverage_object.hotspot_key, + &coverage_object.cbsd_id, + coverage_object.seniority_timestamp, + hex_coverage, + ); + } +} + +pub fn insert_outdoor_coverage( + outdoor: &mut OutdoorCellTree, + hotspot: &PublicKeyBinary, + cbsd_id: &Option, + seniority_timestamp: DateTime, + hex_coverage: UnrankedCoverage, +) { + outdoor + .entry(hex_coverage.location) + .or_default() + .push(OutdoorCoverageLevel { + hotspot_key: hotspot.clone(), + cbsd_id: cbsd_id.clone(), + seniority_timestamp, + signal_level: hex_coverage.signal_level, + signal_power: hex_coverage.signal_power, + assignments: hex_coverage.assignments, + }); +} + +pub fn clone_outdoor_coverage_into_submap( + submap: &mut OutdoorCellTree, + from: &OutdoorCellTree, + coverage_obj: &CoverageObject, +) { + for coverage in &coverage_obj.coverage { + if let Entry::Vacant(e) = submap.entry(coverage.location) { + if let Some(old_coverage_data) = from.get(&coverage.location) { + e.insert(old_coverage_data.clone()); + } + } + } +} + +pub fn into_outdoor_coverage_map( + outdoor: OutdoorCellTree, + boosted_hexes: &impl BoostedHexMap, + epoch_start: DateTime, +) -> impl Iterator + '_ { + outdoor.into_iter().flat_map(move |(hex, radios)| { + let boosted = boosted_hexes.get_current_multiplier(hex, epoch_start); + radios + .into_sorted_vec() + .into_iter() + .enumerate() + .map(move |(rank, cov)| RankedCoverage { + hex, + rank: rank + 1, + hotspot_key: cov.hotspot_key, + cbsd_id: cov.cbsd_id, + assignments: cov.assignments, + boosted, + signal_level: cov.signal_level, + }) + }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::*; + use chrono::NaiveDate; + use hex_assignments::Assignment; + use hextree::Cell; + + #[test] + fn ensure_outdoor_radios_ranked_by_power() { + let mut outdoor_coverage = OutdoorCellTree::default(); + for cov_obj in vec![ + outdoor_cbrs_coverage("1", -946, date(2022, 8, 1)), + outdoor_cbrs_coverage("2", -936, date(2022, 12, 5)), + outdoor_cbrs_coverage("3", -887, date(2022, 12, 2)), + outdoor_cbrs_coverage("4", -887, date(2022, 12, 1)), + outdoor_cbrs_coverage("5", -773, date(2023, 5, 1)), + ] + .into_iter() + { + insert_outdoor_coverage_object(&mut outdoor_coverage, cov_obj); + } + let ranked: HashMap<_, _> = + into_outdoor_coverage_map(outdoor_coverage, &NoBoostedHexes, Utc::now()) + .map(|x| (x.cbsd_id.clone().unwrap(), x)) + .collect(); + assert_eq!(ranked.get("5").unwrap().rank, 1); + assert_eq!(ranked.get("4").unwrap().rank, 2); + assert_eq!(ranked.get("3").unwrap().rank, 3); + assert_eq!(ranked.get("1").unwrap().rank, 5); + assert_eq!(ranked.get("2").unwrap().rank, 4); + } + + fn hex_assignments_mock() -> HexAssignments { + HexAssignments { + footfall: Assignment::A, + urbanized: Assignment::A, + landtype: Assignment::A, + } + } + + fn date(year: i32, month: u32, day: u32) -> DateTime { + NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + } + + fn outdoor_cbrs_coverage( + cbsd_id: &str, + signal_power: i32, + seniority_timestamp: DateTime, + ) -> CoverageObject { + let owner: PublicKeyBinary = "112NqN2WWMwtK29PMzRby62fDydBJfsCLkCAf392stdok48ovNT6" + .parse() + .expect("failed owner parse"); + CoverageObject { + indoor: false, + hotspot_key: owner, + seniority_timestamp, + cbsd_id: Some(cbsd_id.to_string()), + coverage: vec![UnrankedCoverage { + location: Cell::from_raw(0x8a1fb46622dffff).expect("valid h3 cell"), + signal_power, + signal_level: SignalLevel::High, + assignments: hex_assignments_mock(), + }], + } + } +} diff --git a/mobile_config/Cargo.toml b/mobile_config/Cargo.toml index 87b55125c..b1a396c35 100644 --- a/mobile_config/Cargo.toml +++ b/mobile_config/Cargo.toml @@ -45,6 +45,7 @@ task-manager = { path = "../task_manager" } solana-sdk = { workspace = true } custom-tracing = { path = "../custom_tracing", features = ["grpc"] } humantime-serde = { workspace = true } +coverage-map = { path = "../coverage_map" } [dev-dependencies] rand = { workspace = true } diff --git a/mobile_config/src/boosted_hex_info.rs b/mobile_config/src/boosted_hex_info.rs index 6a745e346..cedb34fd9 100644 --- a/mobile_config/src/boosted_hex_info.rs +++ b/mobile_config/src/boosted_hex_info.rs @@ -177,6 +177,12 @@ impl BoostedHexes { } } +impl coverage_map::BoostedHexMap for BoostedHexes { + fn get_current_multiplier(&self, location: Cell, ts: DateTime) -> Option { + self.get_current_multiplier(location, ts) + } +} + pub(crate) mod db { use super::{to_end_ts, to_start_ts, BoostedHexInfo}; use chrono::{DateTime, Duration, Utc};