diff --git a/Cargo.lock b/Cargo.lock index 003135243..6c27516d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,6 +2130,21 @@ dependencies = [ "hextree", ] +[[package]] +name = "coverage-point-calculator" +version = "0.1.0" +dependencies = [ + "chrono", + "coverage-map", + "helium-crypto", + "hex-assignments", + "hextree", + "rstest", + "rust_decimal", + "rust_decimal_macros", + "thiserror", +] + [[package]] name = "cpufeatures" version = "0.2.5" @@ -3240,6 +3255,12 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "goblin" version = "0.5.4" @@ -5940,6 +5961,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -6175,6 +6202,33 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rstest" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" +dependencies = [ + "rstest_macros", + "rustc_version 0.4.0", +] + +[[package]] +name = "rstest_macros" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2 1.0.69", + "quote 1.0.33", + "regex", + "relative-path", + "rustc_version 0.4.0", + "syn 2.0.38", + "unicode-ident", +] + [[package]] name = "rtoolbox" version = "0.0.2" diff --git a/Cargo.toml b/Cargo.toml index 46474b62e..a9bbc425f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ debug = true members = [ "boost_manager", "coverage_map", + "coverage_point_calculator", "custom_tracing", "db_store", "denylist", diff --git a/coverage_point_calculator/Cargo.toml b/coverage_point_calculator/Cargo.toml new file mode 100644 index 000000000..fc59596ee --- /dev/null +++ b/coverage_point_calculator/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "coverage-point-calculator" +version = "0.1.0" +description = "Calculate Coverage Points for hotspots in the Mobile Network" +authors.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +chrono = { workspace = true } +hextree = { workspace = true } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } +thiserror = { workspace = true } +hex-assignments = { path = "../hex_assignments" } +coverage-map = { path = "../coverage_map" } + +[dev-dependencies] +helium-crypto = { workspace = true } +rstest = { version = "0.21.0", default-features = false } diff --git a/coverage_point_calculator/src/hexes.rs b/coverage_point_calculator/src/hexes.rs new file mode 100644 index 000000000..95e973c70 --- /dev/null +++ b/coverage_point_calculator/src/hexes.rs @@ -0,0 +1,133 @@ +use coverage_map::RankedCoverage; +use hex_assignments::assignment::HexAssignments; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use crate::{BoostedHexStatus, RadioType, Result}; + +#[derive(Debug, Clone)] +pub struct CoveredHex { + pub hex: hextree::Cell, + /// Default points received from (RadioType, SignalLevel) pair. + pub base_coverage_points: Decimal, + /// Coverage points including assignment, rank, and boosted hex multipliers. + pub calculated_coverage_points: Decimal, + /// Oracle boosted Assignments + pub assignments: HexAssignments, + pub assignment_multiplier: Decimal, + /// [RankedCoverage::rank] 1-based + pub rank: usize, + pub rank_multiplier: Decimal, + /// Provider boosted multiplier. Will be None if the Radio does not qualify + /// for boosted rewards. + pub boosted_multiplier: Option, +} + +pub(crate) fn clean_covered_hexes( + radio_type: RadioType, + boosted_hex_status: BoostedHexStatus, + ranked_coverage: Vec, +) -> Result> { + // verify all hexes can obtain a base coverage point + let covered_hexes = ranked_coverage + .into_iter() + .map(|ranked| { + let base_coverage_points = radio_type.base_coverage_points(&ranked.signal_level)?; + let rank_multiplier = radio_type.rank_multiplier(ranked.rank); + + let boosted_multiplier = if boosted_hex_status.is_eligible() { + ranked.boosted.map(|boost| boost.get()).map(Decimal::from) + } else { + None + }; + + // hip-103: if a hex is boosted by a service provider >=1x, the oracle + // multiplier will automatically be 1x, regardless of boosted_hex_status. + let assignment_multiplier = if ranked.boosted.is_some() { + dec!(1) + } else { + ranked.assignments.boosting_multiplier() + }; + + let calculated_coverage_points = base_coverage_points + * assignment_multiplier + * rank_multiplier + * boosted_multiplier.unwrap_or(dec!(1)); + + Ok(CoveredHex { + hex: ranked.hex, + base_coverage_points, + calculated_coverage_points, + assignments: ranked.assignments, + assignment_multiplier, + rank: ranked.rank, + rank_multiplier, + boosted_multiplier, + }) + }) + .collect::>>()?; + + Ok(covered_hexes) +} + +pub(crate) fn calculated_coverage_points(covered_hexes: &[CoveredHex]) -> Decimal { + covered_hexes + .iter() + .map(|hex| hex.calculated_coverage_points) + .sum() +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use std::num::NonZeroU32; + + use coverage_map::SignalLevel; + use hex_assignments::Assignment; + + use super::*; + + #[rstest] + #[case(BoostedHexStatus::Eligible)] + #[case(BoostedHexStatus::WifiLocationScoreBelowThreshold(dec!(999)))] + #[case(BoostedHexStatus::RadioThresholdNotMet)] + fn hip_103_provider_boosted_hex_receives_maximum_oracle_boost( + #[case] boost_status: BoostedHexStatus, + ) { + // Regardless of the radio's eligibility to receive provider boosted + // rewards, a boosted hex increases the oracle assignment. + let unboosted_coverage = RankedCoverage { + hotspot_key: vec![1], + cbsd_id: None, + hex: hextree::Cell::from_raw(0x8c2681a3064edff).unwrap(), + rank: 1, + signal_level: SignalLevel::High, + assignments: HexAssignments { + footfall: Assignment::C, + landtype: Assignment::C, + urbanized: Assignment::C, + }, + boosted: NonZeroU32::new(0), + }; + let boosted_coverage = RankedCoverage { + boosted: NonZeroU32::new(5), + ..unboosted_coverage.clone() + }; + + let covered_hexes = clean_covered_hexes( + RadioType::IndoorWifi, + boost_status, + vec![unboosted_coverage, boosted_coverage], + ) + .unwrap(); + + let unboosted = &covered_hexes[0]; + let boosted = &covered_hexes[1]; + + // unboosted receives original multiplier + assert_eq!(dec!(0), unboosted.assignment_multiplier); + + // provider boosted gets oracle assignment bumped to 1x + assert_eq!(dec!(1), boosted.assignment_multiplier); + } +} diff --git a/coverage_point_calculator/src/lib.rs b/coverage_point_calculator/src/lib.rs new file mode 100644 index 000000000..90b986aec --- /dev/null +++ b/coverage_point_calculator/src/lib.rs @@ -0,0 +1,852 @@ +//! +//! Many changes to the rewards algorithm are contained in and across many HIPs. +//! The blog post [MOBILE Proof of Coverage][mobile-poc-blog] contains a more +//! thorough explanation of many of them. It is not exhaustive, but a great +//! place to start. +//! +//! ## Important Fields +//! - [CoveredHex::base_coverage_points] +//! - [HIP-74][modeled-coverage] +//! - reduced cbrs radio coverage points [HIP-113][cbrs-experimental] +//! +//! - [CoveredHex::assignment_multiplier] +//! - [HIP-103][oracle-boosting] +//! - provider boosted hexes increase oracle boosting to 1x +//! +//! - [CoveredHex::rank] +//! - [HIP-105][hex-limits] +//! +//! - [CoveredHex::boosted_multiplier] +//! - must meet minimum subscriber thresholds [HIP-84][provider-boosting] +//! - Wifi Location trust score >0.75 for boosted hex eligibility [HIP-93][wifi-aps] +//! +//! - [CoveragePoints::location_trust_multiplier] +//! - [HIP-98][qos-score] +//! - states 30m requirement for boosted hexes [HIP-107][prevent-gaming] +//! - increase Boosted hex restriction, 30m -> 50m [Pull Request][boosted-hex-restriction] +//! +//! - [CoveragePoints::speedtest_multiplier] +//! - [HIP-74][modeled-coverage] +//! - added "Good" speedtest tier [HIP-98][qos-score] +//! - latency is explicitly under limit in HIP +//! +//! ## Notable Conditions: +//! - [LocationTrust] +//! - If a Radio covers any boosted hexes, [LocationTrust] scores must meet distance requirements, or be degraded. +//! - CBRS Radio's location is always trusted because of GPS. +//! +//! - [Speedtest] +//! - The latest 6 speedtests will be used. +//! - There must be more than 2 speedtests. +//! +//! - [CoveredHex] +//! - If a Radio is not [BoostedHexStatus::Eligible], boost values are removed before calculations. +//! - If a Hex is boosted by a Provider, the Oracle Assignment multiplier is automatically 1x. +//! +//! [modeled-coverage]: https://github.com/helium/HIP/blob/main/0074-mobile-poc-modeled-coverage-rewards.md#outdoor-radios +//! [provider-boosting]: https://github.com/helium/HIP/blob/main/0084-service-provider-hex-boosting.md +//! [wifi-aps]: https://github.com/helium/HIP/blob/main/0093-addition-of-wifi-aps-to-mobile-subdao.md +//! [qos-score]: https://github.com/helium/HIP/blob/main/0098-mobile-subdao-quality-of-service-requirements.md +//! [oracle-boosting]: https://github.com/helium/HIP/blob/main/0103-oracle-hex-boosting.md +//! [hex-limits]: https://github.com/helium/HIP/blob/main/0105-modification-of-mobile-subdao-hex-limits.md +//! [prevent-gaming]: https://github.com/helium/HIP/blob/main/0107-preventing-gaming-within-the-mobile-network.md +//! [cbrs-experimental]: https://github.com/helium/HIP/blob/main/0113-reward-cbrs-as-experimental.md +//! [mobile-poc-blog]: https://docs.helium.com/mobile/proof-of-coverage +//! [boosted-hex-restriction]: https://github.com/helium/oracles/pull/808 +//! +pub use crate::{ + hexes::CoveredHex, + location::LocationTrust, + speedtest::{BytesPs, Speedtest, SpeedtestTier}, +}; +use coverage_map::SignalLevel; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +mod hexes; +mod location; +mod speedtest; + +pub type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("signal level {0:?} not allowed for {1:?}")] + InvalidSignalLevel(SignalLevel, RadioType), +} + +/// Output of calculating coverage points for a Radio. +/// +/// The data in this struct may be different from the input data, but +/// it contains the values used for calculating coverage points. +/// +/// - If more than the allowed speedtests were provided, only the speedtests +/// considered are included here. +/// +/// - When a radio covers boosted hexes, [CoveragePoints::location_trust_scores] will contain a +/// trust score _after_ the boosted hex restriction has been applied. +/// +/// - When a radio is not eligible for boosted hex rewards, [CoveragePoints::covered_hexes] will +/// have no boosted_multiplier values. +#[derive(Debug, Clone)] +pub struct CoveragePoints { + /// Total Rewards Shares earned by the Radio. + /// + /// Includes Coverage and Backhaul. + /// Hex Coverage points * location trust multiplier * speedtest trust multiplier + pub reward_shares: Decimal, + /// Total Points of Coverage for a Radio. + /// + /// Does not include Backhaul. + /// Hex coverage points * location trust multiplier + pub total_coverage_points: Decimal, + /// Coverage Points collected from each Covered Hex + /// + /// Before location trust multiplier is applied. + pub hex_coverage_points: Decimal, + /// Location Trust Multiplier, maximum of 1 + /// + /// Coverage trust of a Radio + pub location_trust_multiplier: Decimal, + /// Speedtest Mulitplier, maximum of 1 + /// + /// Backhaul of a Radio + pub speedtest_multiplier: Decimal, + /// Input Radio Type + pub radio_type: RadioType, + /// Input RadioThreshold + pub radio_threshold: RadioThreshold, + /// Derived Eligibility for Boosted Hex Rewards + pub boosted_hex_eligibility: BoostedHexStatus, + /// Speedtests used in calculcation + pub speedtests: Vec, + /// Location Trust Scores used in calculation + pub location_trust_scores: Vec, + /// Covered Hexes used in calculation + pub covered_hexes: Vec, +} + +impl CoveragePoints { + pub fn new( + radio_type: RadioType, + radio_threshold: RadioThreshold, + speedtests: Vec, + trust_scores: Vec, + ranked_coverage: Vec, + ) -> Result { + let location_trust_scores = location::clean_trust_scores(trust_scores, &ranked_coverage); + let location_trust_multiplier = location::multiplier(radio_type, &location_trust_scores); + + let boost_eligibility = + BoostedHexStatus::new(&radio_type, location_trust_multiplier, &radio_threshold); + + let covered_hexes = + hexes::clean_covered_hexes(radio_type, boost_eligibility, ranked_coverage)?; + let hex_coverage_points = hexes::calculated_coverage_points(&covered_hexes); + + let speedtests = speedtest::clean_speedtests(speedtests); + let speedtest_multiplier = speedtest::multiplier(&speedtests); + + let reward_shares = hex_coverage_points * location_trust_multiplier * speedtest_multiplier; + let total_coverage_points = hex_coverage_points * location_trust_multiplier; + + Ok(CoveragePoints { + reward_shares, + total_coverage_points, + hex_coverage_points, + location_trust_multiplier, + speedtest_multiplier, + radio_type, + radio_threshold, + boosted_hex_eligibility: boost_eligibility, + speedtests, + location_trust_scores, + covered_hexes, + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum BoostedHexStatus { + Eligible, + WifiLocationScoreBelowThreshold(Decimal), + RadioThresholdNotMet, +} + +impl BoostedHexStatus { + fn new( + radio_type: &RadioType, + location_trust_score: Decimal, + radio_threshold: &RadioThreshold, + ) -> Self { + // hip-93: if radio is wifi & location_trust score multiplier < 0.75, no boosting + if radio_type.is_wifi() && location_trust_score < dec!(0.75) { + return Self::WifiLocationScoreBelowThreshold(location_trust_score); + } + + // hip-84: if radio has not met minimum data and subscriber thresholds, no boosting + if !radio_threshold.is_met() { + return Self::RadioThresholdNotMet; + } + + Self::Eligible + } + + fn is_eligible(&self) -> bool { + matches!(self, Self::Eligible) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RadioType { + IndoorWifi, + OutdoorWifi, + IndoorCbrs, + OutdoorCbrs, +} + +impl RadioType { + fn base_coverage_points(&self, signal_level: &SignalLevel) -> Result { + let mult = match self { + RadioType::IndoorWifi => match signal_level { + SignalLevel::High => dec!(400), + SignalLevel::Low => dec!(100), + other => return Err(Error::InvalidSignalLevel(*other, *self)), + }, + RadioType::OutdoorWifi => match signal_level { + SignalLevel::High => dec!(16), + SignalLevel::Medium => dec!(8), + SignalLevel::Low => dec!(4), + SignalLevel::None => dec!(0), + }, + RadioType::IndoorCbrs => match signal_level { + SignalLevel::High => dec!(100), + SignalLevel::Low => dec!(25), + other => return Err(Error::InvalidSignalLevel(*other, *self)), + }, + RadioType::OutdoorCbrs => match signal_level { + SignalLevel::High => dec!(4), + SignalLevel::Medium => dec!(2), + SignalLevel::Low => dec!(1), + SignalLevel::None => dec!(0), + }, + }; + Ok(mult) + } + + fn rank_multiplier(&self, rank: usize) -> Decimal { + match (self, rank) { + // Indoors Radios + (RadioType::IndoorWifi, 1) => dec!(1), + (RadioType::IndoorCbrs, 1) => dec!(1), + // Outdoor Wifi + (RadioType::OutdoorWifi, 1) => dec!(1), + (RadioType::OutdoorWifi, 2) => dec!(0.5), + (RadioType::OutdoorWifi, 3) => dec!(0.25), + // Outdoor Cbrs + (RadioType::OutdoorCbrs, 1) => dec!(1), + (RadioType::OutdoorCbrs, 2) => dec!(0.5), + (RadioType::OutdoorCbrs, 3) => dec!(0.25), + // Radios outside acceptable rank in a hex do not get points for that hex. + _ => dec!(0), + } + } + + pub fn is_wifi(&self) -> bool { + matches!(self, Self::IndoorWifi | Self::OutdoorWifi) + } + + pub fn is_cbrs(&self) -> bool { + matches!(self, Self::IndoorCbrs | Self::OutdoorCbrs) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RadioThreshold { + Verified, + Unverified, +} + +impl RadioThreshold { + fn is_met(&self) -> bool { + matches!(self, Self::Verified) + } +} + +#[cfg(test)] +mod tests { + + use rstest::rstest; + use speedtest::SpeedtestTier; + + use std::num::NonZeroU32; + + use super::*; + use chrono::Utc; + use coverage_map::RankedCoverage; + use hex_assignments::{assignment::HexAssignments, Assignment}; + use rust_decimal_macros::dec; + + #[rstest] + #[case::unboosted(0, dec!(0))] + #[case::minimum_boosted(1, dec!(400))] + #[case::boosted(5, dec!(2000))] + fn hip_103_provider_boost_can_raise_oracle_boost( + #[case] boost_multiplier: u32, + #[case] expected_points: Decimal, + ) { + let wifi = CoveragePoints::new( + RadioType::IndoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_from(Assignment::C), + boosted: NonZeroU32::new(boost_multiplier), + }], + ) + .unwrap(); + + // A Hex with the worst possible oracle boosting assignment. + // The boosting assignment multiplier will be 1x when the hex is provider boosted. + assert_eq!(expected_points, wifi.total_coverage_points); + } + + #[test] + fn hip_84_radio_meets_minimum_subscriber_threshold_for_boosted_hexes() { + let calculate_wifi = |radio_verified: RadioThreshold| { + CoveragePoints::new( + RadioType::IndoorWifi, + radio_verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: NonZeroU32::new(5), + }], + ) + .expect("indoor wifi with location scores") + }; + + let base_points = RadioType::IndoorWifi + .base_coverage_points(&SignalLevel::High) + .unwrap(); + + // Radio meeting the threshold is eligible for boosted hexes. + // Boosted hex provides radio with more than base_points. + let verified_wifi = calculate_wifi(RadioThreshold::Verified); + assert_eq!(base_points * dec!(5), verified_wifi.total_coverage_points); + + // Radio not meeting the threshold is not eligible for boosted hexes. + // Boost from hex is not applied, radio receives base points. + let unverified_wifi = calculate_wifi(RadioThreshold::Unverified); + assert_eq!(base_points, unverified_wifi.total_coverage_points); + } + + #[test] + fn hip_93_wifi_with_low_location_score_receives_no_boosted_hexes() { + let calculate_wifi = |location_trust_scores: Vec| { + CoveragePoints::new( + RadioType::IndoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_scores, + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: NonZeroU32::new(5), + }], + ) + .expect("indoor wifi with location scores") + }; + + let base_points = RadioType::IndoorWifi + .base_coverage_points(&SignalLevel::High) + .unwrap(); + + // Radio with good trust score is eligible for boosted hexes. + // Boosted hex provides radio with more than base_points. + let trusted_wifi = calculate_wifi(location_trust_with_scores(&[dec!(1), dec!(1)])); + assert!(trusted_wifi.location_trust_multiplier > dec!(0.75)); + assert!(trusted_wifi.total_coverage_points > base_points); + + // Radio with poor trust score is not eligible for boosted hexes. + // Boost from hex is not applied, and points are further lowered by poor trust score. + let untrusted_wifi = calculate_wifi(location_trust_with_scores(&[dec!(0.1), dec!(0.2)])); + assert!(untrusted_wifi.location_trust_multiplier < dec!(0.75)); + assert!(untrusted_wifi.total_coverage_points < base_points); + } + + #[test] + fn speedtests_effect_coverage_points() { + let calculate_indoor_cbrs = |speedtests: Vec| { + CoveragePoints::new( + RadioType::IndoorCbrs, + RadioThreshold::Verified, + speedtests, + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: Some("serial".to_string()), + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("indoor cbrs with speedtests") + }; + + let base_coverage_points = RadioType::IndoorCbrs + .base_coverage_points(&SignalLevel::High) + .unwrap(); + + let indoor_cbrs = calculate_indoor_cbrs(speedtest_maximum()); + assert_eq!( + base_coverage_points * SpeedtestTier::Good.multiplier(), + indoor_cbrs.total_coverage_points + ); + + let indoor_cbrs = calculate_indoor_cbrs(vec![ + speedtest_with_download(BytesPs::mbps(88)), + speedtest_with_download(BytesPs::mbps(88)), + ]); + assert_eq!( + base_coverage_points * SpeedtestTier::Acceptable.multiplier(), + indoor_cbrs.total_coverage_points + ); + + let indoor_cbrs = calculate_indoor_cbrs(vec![ + speedtest_with_download(BytesPs::mbps(62)), + speedtest_with_download(BytesPs::mbps(62)), + ]); + assert_eq!( + base_coverage_points * SpeedtestTier::Degraded.multiplier(), + indoor_cbrs.total_coverage_points + ); + + let indoor_cbrs = calculate_indoor_cbrs(vec![ + speedtest_with_download(BytesPs::mbps(42)), + speedtest_with_download(BytesPs::mbps(42)), + ]); + assert_eq!( + base_coverage_points * SpeedtestTier::Poor.multiplier(), + indoor_cbrs.total_coverage_points + ); + + let indoor_cbrs = calculate_indoor_cbrs(vec![ + speedtest_with_download(BytesPs::mbps(25)), + speedtest_with_download(BytesPs::mbps(25)), + ]); + assert_eq!( + base_coverage_points * SpeedtestTier::Fail.multiplier(), + indoor_cbrs.total_coverage_points + ); + } + + #[test] + fn oracle_boosting_assignments_apply_per_hex() { + fn ranked_coverage( + footfall: Assignment, + landtype: Assignment, + urbanized: Assignment, + ) -> RankedCoverage { + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: Some("serial".to_string()), + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: HexAssignments { + footfall, + landtype, + urbanized, + }, + boosted: None, + } + } + + use Assignment::*; + let indoor_cbrs = CoveragePoints::new( + RadioType::IndoorCbrs, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![ + // yellow - POI ≥ 1 Urbanized + ranked_coverage(A, A, A), // 100 + ranked_coverage(A, B, A), // 100 + ranked_coverage(A, C, A), // 100 + // orange - POI ≥ 1 Not Urbanized + ranked_coverage(A, A, B), // 100 + ranked_coverage(A, B, B), // 100 + ranked_coverage(A, C, B), // 100 + // light green - Point of Interest Urbanized + ranked_coverage(B, A, A), // 70 + ranked_coverage(B, B, A), // 70 + ranked_coverage(B, C, A), // 70 + // dark green - Point of Interest Not Urbanized + ranked_coverage(B, A, B), // 50 + ranked_coverage(B, B, B), // 50 + ranked_coverage(B, C, B), // 50 + // light blue - No POI Urbanized + ranked_coverage(C, A, A), // 40 + ranked_coverage(C, B, A), // 30 + ranked_coverage(C, C, A), // 5 + // dark blue - No POI Not Urbanized + ranked_coverage(C, A, B), // 20 + ranked_coverage(C, B, B), // 15 + ranked_coverage(C, C, B), // 3 + // gray - Outside of USA + ranked_coverage(A, A, C), // 0 + ranked_coverage(A, B, C), // 0 + ranked_coverage(A, C, C), // 0 + ranked_coverage(B, A, C), // 0 + ranked_coverage(B, B, C), // 0 + ranked_coverage(B, C, C), // 0 + ranked_coverage(C, A, C), // 0 + ranked_coverage(C, B, C), // 0 + ranked_coverage(C, C, C), // 0 + ], + ) + .expect("indoor cbrs"); + + assert_eq!(dec!(1073), indoor_cbrs.total_coverage_points); + } + + #[rstest] + #[case(RadioType::OutdoorWifi, 1, dec!(16))] + #[case(RadioType::OutdoorWifi, 2, dec!(8))] + #[case(RadioType::OutdoorWifi, 3, dec!(4))] + #[case(RadioType::OutdoorWifi, 42, dec!(0))] + fn outdoor_radios_consider_top_3_ranked_hexes( + #[case] radio_type: RadioType, + #[case] rank: usize, + #[case] expected_points: Decimal, + ) { + let outdoor_wifi = CoveragePoints::new( + radio_type, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("outdoor wifi"); + + assert_eq!(expected_points, outdoor_wifi.total_coverage_points); + } + + #[rstest] + #[case(RadioType::IndoorWifi, 1, dec!(400))] + #[case(RadioType::IndoorWifi, 2, dec!(0))] + #[case(RadioType::IndoorWifi, 42, dec!(0))] + fn indoor_radios_only_consider_first_ranked_hexes( + #[case] radio_type: RadioType, + #[case] rank: usize, + #[case] expected_points: Decimal, + ) { + let indoor_wifi = CoveragePoints::new( + radio_type, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![ + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }, + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 2, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }, + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 42, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }, + ], + ) + .expect("indoor wifi"); + + assert_eq!(expected_points, indoor_wifi.total_coverage_points); + } + + #[test] + fn location_trust_score_multiplier() { + // Location scores are averaged together + let indoor_wifi = CoveragePoints::new( + RadioType::IndoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_with_scores(&[dec!(0.1), dec!(0.2), dec!(0.3), dec!(0.4)]), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("indoor wifi"); + + // Location trust scores is 1/4 + // (0.1 + 0.2 + 0.3 + 0.4) / 4 + assert_eq!(dec!(100), indoor_wifi.total_coverage_points); + } + + #[test] + fn boosted_hex() { + let covered_hexes = vec![ + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::High, + assignments: assignments_maximum(), + boosted: None, + }, + RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level: SignalLevel::Low, + assignments: assignments_maximum(), + boosted: NonZeroU32::new(4), + }, + ]; + let indoor_wifi = CoveragePoints::new( + RadioType::IndoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + covered_hexes.clone(), + ) + .expect("indoor wifi"); + + // The hex with a low signal_level is boosted to the same level as a + // signal_level of High. + assert_eq!(dec!(800), indoor_wifi.total_coverage_points); + } + + #[rstest] + #[case(SignalLevel::High, dec!(4))] + #[case(SignalLevel::Medium, dec!(2))] + #[case(SignalLevel::Low, dec!(1))] + #[case(SignalLevel::None, dec!(0))] + fn outdoor_cbrs_base_coverage_points( + #[case] signal_level: SignalLevel, + #[case] expected: Decimal, + ) { + let outdoor_cbrs = CoveragePoints::new( + RadioType::OutdoorCbrs, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: Some("serial".to_string()), + hex: hex_location(), + rank: 1, + signal_level, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("outdoor cbrs"); + + assert_eq!(expected, outdoor_cbrs.total_coverage_points); + } + + #[rstest] + #[case(SignalLevel::High, dec!(100))] + #[case(SignalLevel::Low, dec!(25))] + fn indoor_cbrs_base_coverage_points( + #[case] signal_level: SignalLevel, + #[case] expected: Decimal, + ) { + let indoor_cbrs = CoveragePoints::new( + RadioType::IndoorCbrs, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: Some("serial".to_string()), + hex: hex_location(), + rank: 1, + signal_level, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("indoor cbrs"); + + assert_eq!(expected, indoor_cbrs.total_coverage_points); + } + + #[rstest] + #[case(SignalLevel::High, dec!(16))] + #[case(SignalLevel::Medium, dec!(8))] + #[case(SignalLevel::Low, dec!(4))] + #[case(SignalLevel::None, dec!(0))] + fn outdoor_wifi_base_coverage_points( + #[case] signal_level: SignalLevel, + #[case] expected: Decimal, + ) { + let outdoor_wifi = CoveragePoints::new( + RadioType::OutdoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: Some("serial".to_string()), + hex: hex_location(), + rank: 1, + signal_level, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("indoor cbrs"); + + assert_eq!(expected, outdoor_wifi.total_coverage_points); + } + + #[rstest] + #[case(SignalLevel::High, dec!(400))] + #[case(SignalLevel::Low, dec!(100))] + fn indoor_wifi_base_coverage_points( + #[case] signal_level: SignalLevel, + #[case] expected: Decimal, + ) { + let indoor_wifi = CoveragePoints::new( + RadioType::IndoorWifi, + RadioThreshold::Verified, + speedtest_maximum(), + location_trust_maximum(), + vec![RankedCoverage { + hotspot_key: pubkey(), + cbsd_id: None, + hex: hex_location(), + rank: 1, + signal_level, + assignments: assignments_maximum(), + boosted: None, + }], + ) + .expect("indoor wifi"); + + assert_eq!(expected, indoor_wifi.total_coverage_points); + } + + fn hex_location() -> hextree::Cell { + hextree::Cell::from_raw(0x8c2681a3064edff).unwrap() + } + + fn assignments_maximum() -> HexAssignments { + HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + } + } + + fn assignments_from(assignment: Assignment) -> HexAssignments { + HexAssignments { + footfall: assignment, + landtype: assignment, + urbanized: assignment, + } + } + + fn speedtest_maximum() -> Vec { + vec![ + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + ] + } + + fn speedtest_with_download(download: BytesPs) -> Speedtest { + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: download, + latency_millis: 15, + timestamp: Utc::now(), + } + } + + fn location_trust_maximum() -> Vec { + vec![LocationTrust { + meters_to_asserted: 1, + trust_score: dec!(1.0), + }] + } + + fn location_trust_with_scores(trust_scores: &[Decimal]) -> Vec { + trust_scores + .to_owned() + .iter() + .copied() + .map(|trust_score| LocationTrust { + meters_to_asserted: 1, + trust_score, + }) + .collect() + } + + fn pubkey() -> Vec { + vec![1] + } +} diff --git a/coverage_point_calculator/src/location.rs b/coverage_point_calculator/src/location.rs new file mode 100644 index 000000000..a75ef88eb --- /dev/null +++ b/coverage_point_calculator/src/location.rs @@ -0,0 +1,168 @@ +use coverage_map::RankedCoverage; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use crate::RadioType; + +/// When a Radio is covering any boosted hexes, it's trust score location must +/// be within this distance to it's asserted location. Otherwise the trust_score +/// will be capped at 0.25x. +const RESTRICTIVE_MAX_DISTANCE: Meters = 50; + +type Meters = u32; + +#[derive(Debug, Clone, PartialEq)] +pub struct LocationTrust { + pub meters_to_asserted: Meters, + pub trust_score: Decimal, +} + +pub(crate) fn clean_trust_scores( + trust_scores: Vec, + ranked_coverage: &[RankedCoverage], +) -> Vec { + let any_boosted_hexes = ranked_coverage.iter().any(|hex| hex.boosted.is_some()); + + if any_boosted_hexes { + trust_scores + .into_iter() + .map(LocationTrust::into_boosted) + .collect() + } else { + trust_scores + } +} + +pub(crate) fn multiplier(radio_type: RadioType, trust_scores: &[LocationTrust]) -> Decimal { + // CBRS radios are always trusted because they have internal GPS + if radio_type.is_cbrs() { + return dec!(1); + } + + let count = Decimal::from(trust_scores.len()); + let scores: Decimal = trust_scores.iter().map(|l| l.trust_score).sum(); + + scores / count +} + +impl LocationTrust { + fn into_boosted(self) -> Self { + // Cap multipliers to 0.25x when a radio covers _any_ boosted hex + // and it's distance to asserted is above the threshold. + let trust_score = if self.meters_to_asserted > RESTRICTIVE_MAX_DISTANCE { + dec!(0.25).min(self.trust_score) + } else { + self.trust_score + }; + + LocationTrust { + trust_score, + meters_to_asserted: self.meters_to_asserted, + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use coverage_map::SignalLevel; + use hex_assignments::{assignment::HexAssignments, Assignment}; + + use super::*; + + #[test] + fn all_locations_within_max_boosted_distance() { + let trust_scores = vec![ + LocationTrust { + meters_to_asserted: 49, + trust_score: dec!(0.5), + }, + LocationTrust { + meters_to_asserted: 50, + trust_score: dec!(0.5), + }, + ]; + let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); + let unboosted = clean_trust_scores(trust_scores, &[]); + + assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &boosted)); + assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); + } + + #[test] + fn all_locations_past_max_boosted_distance() { + let trust_scores = vec![ + LocationTrust { + meters_to_asserted: 51, + trust_score: dec!(0.5), + }, + LocationTrust { + meters_to_asserted: 100, + trust_score: dec!(0.5), + }, + ]; + + let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); + let unboosted = clean_trust_scores(trust_scores, &[]); + + assert_eq!(dec!(0.25), multiplier(RadioType::IndoorWifi, &boosted)); + assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); + } + + #[test] + fn locations_around_max_boosted_distance() { + let trust_scores = vec![ + LocationTrust { + meters_to_asserted: 50, + trust_score: dec!(0.5), + }, + LocationTrust { + meters_to_asserted: 51, + trust_score: dec!(0.5), + }, + ]; + + let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); + let unboosted = clean_trust_scores(trust_scores, &[]); + + // location past distance limit trust score is degraded + let degraded_mult = (dec!(0.5) + dec!(0.25)) / dec!(2); + assert_eq!(degraded_mult, multiplier(RadioType::IndoorWifi, &boosted)); + // location past distance limit trust score is untouched + assert_eq!(dec!(0.5), multiplier(RadioType::IndoorWifi, &unboosted)); + } + + #[test] + fn cbrs_trust_score_bypassed_for_gps_trust() { + // CBRS radios have GPS units in them, they are always trusted, + // regardless of their score or distance provided. + + let trust_scores = vec![LocationTrust { + meters_to_asserted: 99999, + trust_score: dec!(0), + }]; + + let boosted = clean_trust_scores(trust_scores.clone(), &boosted_ranked_coverage()); + let unboosted = clean_trust_scores(trust_scores, &[]); + + assert_eq!(dec!(1), multiplier(RadioType::IndoorCbrs, &boosted)); + assert_eq!(dec!(1), multiplier(RadioType::IndoorCbrs, &unboosted)); + } + + fn boosted_ranked_coverage() -> Vec { + vec![RankedCoverage { + hex: hextree::Cell::from_raw(0x8c2681a3064edff).unwrap(), + rank: 1, + hotspot_key: vec![], + cbsd_id: None, + signal_level: SignalLevel::High, + assignments: HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + }, + boosted: NonZeroU32::new(5), + }] + } +} diff --git a/coverage_point_calculator/src/speedtest.rs b/coverage_point_calculator/src/speedtest.rs new file mode 100644 index 000000000..37980ce09 --- /dev/null +++ b/coverage_point_calculator/src/speedtest.rs @@ -0,0 +1,254 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +const MIN_REQUIRED_SPEEDTEST_SAMPLES: usize = 2; +const MAX_ALLOWED_SPEEDTEST_SAMPLES: usize = 6; + +type Millis = u32; + +/// Bytes per second +#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd)] +pub struct BytesPs(u64); + +impl BytesPs { + const BYTES_PER_MEGABYTE: u64 = 125_000; + + pub fn new(bytes_per_second: u64) -> Self { + Self(bytes_per_second) + } + + pub fn mbps(megabytes_per_second: u64) -> Self { + Self(megabytes_per_second * Self::BYTES_PER_MEGABYTE) + } + + fn as_mbps(&self) -> u64 { + self.0 / Self::BYTES_PER_MEGABYTE + } +} + +pub(crate) fn clean_speedtests(speedtests: Vec) -> Vec { + let mut cleaned = speedtests; + // sort newest to oldest + cleaned.sort_by_key(|test| std::cmp::Reverse(test.timestamp)); + cleaned.truncate(MAX_ALLOWED_SPEEDTEST_SAMPLES); + cleaned +} + +pub(crate) fn multiplier(speedtests: &[Speedtest]) -> Decimal { + if speedtests.len() < MIN_REQUIRED_SPEEDTEST_SAMPLES { + return dec!(0); + } + + let avg = Speedtest::avg(speedtests); + avg.multiplier() +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Speedtest { + pub upload_speed: BytesPs, + pub download_speed: BytesPs, + pub latency_millis: u32, + pub timestamp: DateTime, +} + +impl Speedtest { + pub fn multiplier(&self) -> Decimal { + let upload = SpeedtestTier::from_upload(self.upload_speed); + let download = SpeedtestTier::from_download(self.download_speed); + let latency = SpeedtestTier::from_latency(self.latency_millis); + + let tier = upload.min(download).min(latency); + tier.multiplier() + } + + pub fn avg(speedtests: &[Self]) -> Self { + let mut download = 0; + let mut upload = 0; + let mut latency = 0; + + for test in speedtests { + upload += test.upload_speed.0; + download += test.download_speed.0; + latency += test.latency_millis; + } + + let count = speedtests.len(); + Self { + upload_speed: BytesPs::new(upload / count as u64), + download_speed: BytesPs::new(download / count as u64), + latency_millis: latency / count as u32, + timestamp: Utc::now(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum SpeedtestTier { + Good = 4, + Acceptable = 3, + Degraded = 2, + Poor = 1, + Fail = 0, +} + +impl SpeedtestTier { + pub fn multiplier(self) -> Decimal { + match self { + SpeedtestTier::Good => dec!(1.00), + SpeedtestTier::Acceptable => dec!(0.75), + SpeedtestTier::Degraded => dec!(0.50), + SpeedtestTier::Poor => dec!(0.25), + SpeedtestTier::Fail => dec!(0), + } + } + + fn from_download(bytes: BytesPs) -> Self { + match bytes.as_mbps() { + 100.. => Self::Good, + 75.. => Self::Acceptable, + 50.. => Self::Degraded, + 30.. => Self::Poor, + _ => Self::Fail, + } + } + + fn from_upload(bytes: BytesPs) -> Self { + match bytes.as_mbps() { + 10.. => Self::Good, + 8.. => Self::Acceptable, + 5.. => Self::Degraded, + 2.. => Self::Poor, + _ => Self::Fail, + } + } + + fn from_latency(millis: Millis) -> Self { + match millis { + 00..=49 => Self::Good, + 50..=59 => Self::Acceptable, + 60..=74 => Self::Degraded, + 75..=99 => Self::Poor, + _ => Self::Fail, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speedtest_teirs() { + use SpeedtestTier::*; + // download + assert_eq!(Good, SpeedtestTier::from_download(BytesPs::mbps(100))); + assert_eq!(Acceptable, SpeedtestTier::from_download(BytesPs::mbps(80))); + assert_eq!(Degraded, SpeedtestTier::from_download(BytesPs::mbps(62))); + assert_eq!(Poor, SpeedtestTier::from_download(BytesPs::mbps(42))); + assert_eq!(Fail, SpeedtestTier::from_download(BytesPs::mbps(20))); + + // upload + assert_eq!(Good, SpeedtestTier::from_upload(BytesPs::mbps(10))); + assert_eq!(Acceptable, SpeedtestTier::from_upload(BytesPs::mbps(8))); + assert_eq!(Degraded, SpeedtestTier::from_upload(BytesPs::mbps(6))); + assert_eq!(Poor, SpeedtestTier::from_upload(BytesPs::mbps(4))); + assert_eq!(Fail, SpeedtestTier::from_upload(BytesPs::mbps(1))); + + // latency + assert_eq!(Good, SpeedtestTier::from_latency(49)); + assert_eq!(Acceptable, SpeedtestTier::from_latency(59)); + assert_eq!(Degraded, SpeedtestTier::from_latency(74)); + assert_eq!(Poor, SpeedtestTier::from_latency(99)); + assert_eq!(Fail, SpeedtestTier::from_latency(101)); + } + + #[test] + fn minimum_required_speedtests_provided_for_multiplier_above_zero() { + let speedtest = Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }; + let speedtests = |num: usize| std::iter::repeat(speedtest).take(num).collect::>(); + + assert_eq!( + dec!(0), + multiplier(&speedtests(MIN_REQUIRED_SPEEDTEST_SAMPLES - 1)) + ); + assert_eq!( + dec!(1), + multiplier(&speedtests(MIN_REQUIRED_SPEEDTEST_SAMPLES)) + ); + } + + #[test] + fn restrict_to_maximum_speedtests_allowed() { + let base = Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }; + let speedtests = std::iter::repeat(base).take(10).collect(); + let speedtests = clean_speedtests(speedtests); + + assert_eq!(MAX_ALLOWED_SPEEDTEST_SAMPLES, speedtests.len()); + } + + #[test] + fn speedtests_ordered_newest_to_oldest() { + let make_speedtest = |timestamp: DateTime, latency: Millis| Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: latency, + timestamp, + }; + + // Intersperse new and old speedtests. + // new speedtests have 1.0 multipliers + // old speedtests have 0.0 multipliers + let speedtests = clean_speedtests(vec![ + make_speedtest(date(2024, 4, 6), 15), + make_speedtest(date(2022, 4, 6), 999), + // -- + make_speedtest(date(2024, 4, 5), 15), + make_speedtest(date(2022, 4, 5), 999), + // -- + make_speedtest(date(2024, 4, 4), 15), + make_speedtest(date(2022, 4, 4), 999), + // -- + make_speedtest(date(2022, 4, 3), 999), + make_speedtest(date(2024, 4, 3), 15), + // -- + make_speedtest(date(2024, 4, 2), 15), + make_speedtest(date(2022, 4, 2), 999), + // -- + make_speedtest(date(2024, 4, 1), 15), + make_speedtest(date(2022, 4, 1), 999), + ]); + + // Old speedtests should be unused + assert_eq!(dec!(1), multiplier(&speedtests)); + } + + #[test] + fn test_real_bytes_per_second() { + // Random sampling from database for a download speed that should be + // "Acceptable". Other situational tests go through the ::mbps() + // constructor, so will always be consistent with each other. + assert_eq!( + SpeedtestTier::Acceptable, + SpeedtestTier::from_download(BytesPs::new(11_702_687)) + ); + } + + fn date(year: i32, month: u32, day: u32) -> DateTime { + chrono::NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + } +} diff --git a/coverage_point_calculator/tests/coverage_point_calculator.rs b/coverage_point_calculator/tests/coverage_point_calculator.rs new file mode 100644 index 000000000..2ab08a945 --- /dev/null +++ b/coverage_point_calculator/tests/coverage_point_calculator.rs @@ -0,0 +1,468 @@ +use std::num::NonZeroU32; + +use chrono::Utc; +use coverage_map::{BoostedHexMap, RankedCoverage, SignalLevel, UnrankedCoverage}; +use coverage_point_calculator::{ + BytesPs, CoveragePoints, LocationTrust, RadioThreshold, RadioType, Result, Speedtest, + SpeedtestTier, +}; +use hex_assignments::{assignment::HexAssignments, Assignment}; +use rust_decimal_macros::dec; + +#[test] +fn base_radio_coverage_points() { + let speedtests = vec![ + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + ]; + let location_trust_scores = vec![LocationTrust { + meters_to_asserted: 1, + trust_score: dec!(1.0), + }]; + + let hexes = vec![RankedCoverage { + hotspot_key: vec![1], + cbsd_id: None, + hex: hextree::Cell::from_raw(0x8c2681a3064edff).unwrap(), + rank: 1, + signal_level: SignalLevel::High, + assignments: HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + }, + boosted: NonZeroU32::new(0), + }]; + + for (radio_type, expcted_base_coverage_point) in [ + (RadioType::IndoorWifi, dec!(400)), + (RadioType::IndoorCbrs, dec!(100)), + (RadioType::OutdoorWifi, dec!(16)), + (RadioType::OutdoorCbrs, dec!(4)), + ] { + let coverage_points = CoveragePoints::new( + radio_type, + RadioThreshold::Verified, + speedtests.clone(), + location_trust_scores.clone(), + hexes.clone(), + ) + .unwrap(); + + assert_eq!( + expcted_base_coverage_point, + coverage_points.total_coverage_points + ); + } +} + +#[test] +fn radios_with_coverage() { + // Enough hexes will be provided to each type of radio, that they are + // awarded 400 coverage points. + + let base_hex = RankedCoverage { + hotspot_key: vec![1], + cbsd_id: None, + hex: hextree::Cell::from_raw(0x8c2681a3064edff).unwrap(), + rank: 1, + signal_level: SignalLevel::High, + assignments: HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + }, + boosted: NonZeroU32::new(0), + }; + let base_hex_iter = std::iter::repeat(base_hex); + + let default_speedtests = vec![ + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + Speedtest { + upload_speed: BytesPs::mbps(15), + download_speed: BytesPs::mbps(150), + latency_millis: 15, + timestamp: Utc::now(), + }, + ]; + let default_location_trust_scores = vec![LocationTrust { + meters_to_asserted: 1, + trust_score: dec!(1.0), + }]; + + for (radio_type, num_hexes) in [ + (RadioType::IndoorWifi, 1), + (RadioType::IndoorCbrs, 4), + (RadioType::OutdoorWifi, 25), + (RadioType::OutdoorCbrs, 100), + ] { + let coverage_points = CoveragePoints::new( + radio_type, + RadioThreshold::Verified, + default_speedtests.clone(), + default_location_trust_scores.clone(), + base_hex_iter.clone().take(num_hexes).collect(), + ) + .unwrap(); + + assert_eq!(dec!(400), coverage_points.total_coverage_points); + } +} + +#[test] +fn cbrs_with_mixed_signal_level_coverage() -> Result { + // Scenario One + let coverage_points = indoor_cbrs_radio( + SpeedtestTier::Good, + &[ + top_ranked_coverage(0x8c2681a3064d9ff, SignalLevel::High), + top_ranked_coverage(0x8c2681a3065d3ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a306635ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3066e7ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3065adff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a339a4bff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3065d7ff, SignalLevel::Low), + ], + )?; + + assert_eq!(dec!(250), coverage_points.reward_shares); + + Ok(()) +} + +#[test] +fn cbrs_with_partially_overlapping_coverage_and_differing_speedtests() -> Result { + // Scenario two + // Two radios, with a single overlapping hex and differing speedtest scores. + let radio_1 = indoor_cbrs_radio( + SpeedtestTier::Degraded, + &[ + top_ranked_coverage(0x8c2681a3064d9ff, SignalLevel::High), + second_ranked_coverage(0x8c2681a3065d3ff, SignalLevel::Low), // This hex is shared + top_ranked_coverage(0x8c2681a306635ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3066e7ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3065adff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a339a4bff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3065d7ff, SignalLevel::Low), + ], + )?; + + let radio_2 = indoor_cbrs_radio( + SpeedtestTier::Good, + &[ + top_ranked_coverage(0x8c2681a30641dff, SignalLevel::High), + top_ranked_coverage(0x8c2681a3065d3ff, SignalLevel::Low), // This hex is shared + top_ranked_coverage(0x8c2681a3066a9ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a306607ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3066e9ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a306481ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a302991ff, SignalLevel::Low), + ], + )?; + + assert_eq!(dec!(112.5), radio_1.reward_shares); + assert_eq!(dec!(250), radio_2.reward_shares); + + Ok(()) +} + +#[test] +fn cbrs_with_wholly_overlapping_coverage_and_differing_speedtests() -> Result { + // Scenario Three + // All radios cover the same hexes. + // Seniority timestamps determine rank. + // Only the first ranked radio (radio_4) should receive rewards. + + let mut coverage_map_builder = coverage_map::CoverageMapBuilder::default(); + let mut insert_coverage = |cbsd_id: &str, timestamp: &str| { + coverage_map_builder.insert_coverage_object(coverage_map::CoverageObject { + indoor: true, + hotspot_key: vec![], + cbsd_id: Some(cbsd_id.to_string()), + seniority_timestamp: timestamp.parse().expect("valid timestamp"), + coverage: vec![ + unranked_coverage(0x8c2681a3064d9ff, SignalLevel::High), + unranked_coverage(0x8c2681a3065d3ff, SignalLevel::Low), + unranked_coverage(0x8c2681a306635ff, SignalLevel::Low), + unranked_coverage(0x8c2681a3066e7ff, SignalLevel::Low), + unranked_coverage(0x8c2681a3065adff, SignalLevel::Low), + unranked_coverage(0x8c2681a339a4bff, SignalLevel::Low), + unranked_coverage(0x8c2681a3065d7ff, SignalLevel::Low), + ], + }) + }; + + insert_coverage("serial-1", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-2", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-3", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-4", "2022-01-31 00:00:00.000000000 UTC"); // earliest + insert_coverage("serial-5", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-6", "2022-02-02 00:00:00.000000000 UTC"); // latest + + let map = coverage_map_builder.build(&NoBoostedHexes, Utc::now()); + + let radio_1 = indoor_cbrs_radio(SpeedtestTier::Poor, map.get_cbrs_coverage("serial-1"))?; + let radio_2 = indoor_cbrs_radio(SpeedtestTier::Poor, map.get_cbrs_coverage("serial-2"))?; + let radio_3 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-3"))?; + let radio_4 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-4"))?; + let radio_5 = indoor_cbrs_radio(SpeedtestTier::Fail, map.get_cbrs_coverage("serial-5"))?; + let radio_6 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-6"))?; + + assert_eq!(dec!(0), radio_1.reward_shares); + assert_eq!(dec!(0), radio_2.reward_shares); + assert_eq!(dec!(0), radio_3.reward_shares); + assert_eq!(dec!(250), radio_4.reward_shares); + assert_eq!(dec!(0), radio_5.reward_shares); + assert_eq!(dec!(0), radio_6.reward_shares); + + Ok(()) +} + +#[test] +fn cbrs_outdoor_with_mixed_signal_level_coverage() -> Result { + // Scenario four + // Outdoor Cbrs with mixed signal level coverage + + let radio = CoveragePoints::new( + RadioType::OutdoorCbrs, + RadioThreshold::Verified, + speedtests(SpeedtestTier::Good), + vec![], // Location Trust is ignored for Cbrs + vec![ + top_ranked_coverage(0x8c2681a3064d9ff, SignalLevel::High), + top_ranked_coverage(0x8c2681a3065d3ff, SignalLevel::High), + top_ranked_coverage(0x8c2681a306635ff, SignalLevel::Medium), + top_ranked_coverage(0x8c2681a3066e7ff, SignalLevel::Medium), + top_ranked_coverage(0x8c2681a3065adff, SignalLevel::Medium), + top_ranked_coverage(0x8c2681a339a4bff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a3065d7ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a306481ff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a30648bff, SignalLevel::Low), + top_ranked_coverage(0x8c2681a30646bff, SignalLevel::Low), + ], + ) + .unwrap(); + + assert_eq!(dec!(19), radio.reward_shares); + + Ok(()) +} + +#[test] +fn cbrs_outdoor_with_single_overlapping_coverage() -> Result { + // Scenario Five + // 2 radios overlapping a single hex with a medium Signal Level. + // First radio has seniority. + + let mut coverage_map_builder = coverage_map::CoverageMapBuilder::default(); + + coverage_map_builder.insert_coverage_object(coverage_map::CoverageObject { + indoor: false, + hotspot_key: vec![1], + cbsd_id: Some("serial-1".to_string()), + seniority_timestamp: "2022-02-01 00:00:00.000000000 UTC" + .parse() + .expect("valid timestamp"), + coverage: vec![ + unranked_coverage(0x8c2681a302991ff, SignalLevel::High), + unranked_coverage(0x8c2681a306601ff, SignalLevel::High), + unranked_coverage(0x8c2681a306697ff, SignalLevel::High), + unranked_coverage(0x8c2681a3028a7ff, SignalLevel::Medium), // This hex is shared + unranked_coverage(0x8c2681a3064c1ff, SignalLevel::Medium), + unranked_coverage(0x8c2681a30671bff, SignalLevel::Low), + unranked_coverage(0x8c2681a306493ff, SignalLevel::Low), + unranked_coverage(0x8c2681a30659dff, SignalLevel::Low), + ], + }); + coverage_map_builder.insert_coverage_object(coverage_map::CoverageObject { + indoor: false, + hotspot_key: vec![2], + cbsd_id: Some("serial-2".to_string()), + seniority_timestamp: "2022-02-01 00:00:01.000000000 UTC" + .parse() + .expect("valid timestamp"), + coverage: vec![ + unranked_coverage(0x8c2681a3066abff, SignalLevel::High), + unranked_coverage(0x8c2681a3028a7ff, SignalLevel::Medium), // This hex is shared + unranked_coverage(0x8c2681a3066a9ff, SignalLevel::Low), + unranked_coverage(0x8c2681a3066a5ff, SignalLevel::Low), + unranked_coverage(0x8c2681a30640dff, SignalLevel::Low), + ], + }); + + let map = coverage_map_builder.build(&NoBoostedHexes, Utc::now()); + + let radio_1 = outdoor_cbrs_radio(SpeedtestTier::Degraded, map.get_cbrs_coverage("serial-1"))?; + let radio_2 = outdoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-2"))?; + + assert_eq!(dec!(19) * dec!(0.5), radio_1.reward_shares); + assert_eq!(dec!(8), radio_2.reward_shares); + + Ok(()) +} + +#[test] +fn cbrs_indoor_with_wholly_overlapping_coverage_and_no_failing_speedtests() -> Result { + // Scenario Six + // Similar to Scenario Three, but there are no failing speedtests. + // Radios have the same coverage. + + let mut coverage_map_builder = coverage_map::CoverageMapBuilder::default(); + let mut insert_coverage = |cbsd_id: &str, timestamp: &str| { + coverage_map_builder.insert_coverage_object(coverage_map::CoverageObject { + indoor: true, + hotspot_key: vec![0], + cbsd_id: Some(cbsd_id.to_string()), + seniority_timestamp: timestamp.parse().expect("valid timestamp"), + coverage: vec![ + unranked_coverage(0x8c2681a3064d9ff, SignalLevel::High), + unranked_coverage(0x8c2681a3065d3ff, SignalLevel::Low), + unranked_coverage(0x8c2681a306635ff, SignalLevel::Low), + unranked_coverage(0x8c2681a3066e7ff, SignalLevel::Low), + unranked_coverage(0x8c2681a3065adff, SignalLevel::Low), + unranked_coverage(0x8c2681a339a4bff, SignalLevel::Low), + unranked_coverage(0x8c2681a3065d7ff, SignalLevel::Low), + ], + }) + }; + + insert_coverage("serial-1", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-2", "2022-01-31 00:00:00.000000000 UTC"); // Oldest + insert_coverage("serial-3", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-4", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-5", "2022-02-01 00:00:00.000000000 UTC"); + insert_coverage("serial-6", "2022-02-02 00:00:00.000000000 UTC"); // Newest + let map = coverage_map_builder.build(&NoBoostedHexes, Utc::now()); + + let radio_1 = indoor_cbrs_radio(SpeedtestTier::Poor, map.get_cbrs_coverage("serial-1"))?; + let radio_2 = indoor_cbrs_radio(SpeedtestTier::Poor, map.get_cbrs_coverage("serial-2"))?; + let radio_3 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-3"))?; + let radio_4 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-4"))?; + let radio_5 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-5"))?; + let radio_6 = indoor_cbrs_radio(SpeedtestTier::Good, map.get_cbrs_coverage("serial-6"))?; + + assert_eq!(dec!(0), radio_1.reward_shares); + assert_eq!(dec!(62.5), radio_2.reward_shares); + assert_eq!(dec!(0), radio_3.reward_shares); + assert_eq!(dec!(0), radio_4.reward_shares); + assert_eq!(dec!(0), radio_5.reward_shares); + assert_eq!(dec!(0), radio_6.reward_shares); + + Ok(()) +} + +fn indoor_cbrs_radio( + speedtest_tier: SpeedtestTier, + coverage: &[RankedCoverage], +) -> Result { + CoveragePoints::new( + RadioType::IndoorCbrs, + RadioThreshold::Verified, + speedtests(speedtest_tier), + vec![], + coverage.to_owned(), + ) +} + +fn outdoor_cbrs_radio( + speedtest_tier: SpeedtestTier, + coverage: &[RankedCoverage], +) -> Result { + CoveragePoints::new( + RadioType::OutdoorCbrs, + RadioThreshold::Verified, + speedtests(speedtest_tier), + vec![], + coverage.to_owned(), + ) +} + +struct NoBoostedHexes; +impl BoostedHexMap for NoBoostedHexes { + fn get_current_multiplier( + &self, + _cell: hextree::Cell, + _ts: chrono::DateTime, + ) -> Option { + None + } +} + +fn speedtests(tier: SpeedtestTier) -> Vec { + // SpeedtestTier is determined solely by upload_speed. + // Other values are far surpassing ::Good. + let upload_speed = BytesPs::mbps(match tier { + SpeedtestTier::Good => 10, + SpeedtestTier::Acceptable => 8, + SpeedtestTier::Degraded => 5, + SpeedtestTier::Poor => 2, + SpeedtestTier::Fail => 0, + }); + + vec![ + Speedtest { + upload_speed, + download_speed: BytesPs::mbps(150), + latency_millis: 0, + timestamp: Utc::now(), + }, + Speedtest { + upload_speed, + download_speed: BytesPs::mbps(150), + latency_millis: 0, + timestamp: Utc::now(), + }, + ] +} + +fn top_ranked_coverage(hex: u64, signal_level: SignalLevel) -> RankedCoverage { + ranked_coverage(hex, 1, signal_level) +} + +fn second_ranked_coverage(hex: u64, signal_level: SignalLevel) -> RankedCoverage { + ranked_coverage(hex, 2, signal_level) +} + +fn ranked_coverage(hex: u64, rank: usize, signal_level: SignalLevel) -> RankedCoverage { + RankedCoverage { + hex: hextree::Cell::from_raw(hex).expect("valid h3 hex"), + rank, + hotspot_key: vec![1], + cbsd_id: Some("serial".to_string()), + assignments: HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + }, + boosted: None, + signal_level, + } +} + +fn unranked_coverage(hex: u64, signal_level: SignalLevel) -> UnrankedCoverage { + UnrankedCoverage { + location: hextree::Cell::from_raw(hex).expect("valid h3 hex"), + signal_power: 42, + signal_level, + assignments: HexAssignments { + footfall: Assignment::A, + landtype: Assignment::A, + urbanized: Assignment::A, + }, + } +}