use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::{Result, SynapseError};
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct NetworkData {
pub down_mbps: Option<f64>,
pub up_mbps: Option<f64>,
pub ping_ms: Option<f64>,
pub jitter_ms: Option<f64>,
pub packet_loss_percent: Option<f64>,
pub rssi_dbm: Option<f64>,
pub noise_dbm: Option<f64>,
pub channel_width_mhz: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ScoreBand {
Critical,
Poor,
Fair,
Good,
Excellent,
}
impl ScoreBand {
pub fn from_score(score: f64) -> Self {
if !score.is_finite() || score < 50.0 {
Self::Critical
} else if score < 150.0 {
Self::Poor
} else if score < 400.0 {
Self::Fair
} else if score < 1000.0 {
Self::Good
} else {
Self::Excellent
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::Poor => "poor",
Self::Fair => "fair",
Self::Good => "good",
Self::Excellent => "excellent",
}
}
}
impl fmt::Display for ScoreBand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl NetworkData {
const EPSILON: f64 = 1e-7;
const JITTER_WEIGHT: f64 = 3.0;
const INTEGRITY_EXPONENT: f64 = 10.0;
const WIDTH_BASELINE_MHZ: f64 = 20.0;
pub const fn new() -> Self {
Self {
down_mbps: None,
up_mbps: None,
ping_ms: None,
jitter_ms: None,
packet_loss_percent: None,
rssi_dbm: None,
noise_dbm: None,
channel_width_mhz: None,
}
}
pub fn with_down_mbps(mut self, v: f64) -> Self {
self.down_mbps = Some(v);
self
}
pub fn with_up_mbps(mut self, v: f64) -> Self {
self.up_mbps = Some(v);
self
}
pub fn with_ping_ms(mut self, v: f64) -> Self {
self.ping_ms = Some(v);
self
}
pub fn with_jitter_ms(mut self, v: f64) -> Self {
self.jitter_ms = Some(v);
self
}
pub fn with_packet_loss_percent(mut self, v: f64) -> Self {
self.packet_loss_percent = Some(v);
self
}
pub fn with_rssi_dbm(mut self, v: f64) -> Self {
self.rssi_dbm = Some(v);
self
}
pub fn with_noise_dbm(mut self, v: f64) -> Self {
self.noise_dbm = Some(v);
self
}
pub fn with_channel_width_mhz(mut self, v: f64) -> Self {
self.channel_width_mhz = Some(v);
self
}
pub fn has_performance_data(&self) -> bool {
self.down_mbps.is_some()
&& self.up_mbps.is_some()
&& self.ping_ms.is_some()
&& self.jitter_ms.is_some()
&& self.packet_loss_percent.is_some()
}
pub fn has_wireless_data(&self) -> bool {
self.rssi_dbm.is_some() && self.noise_dbm.is_some() && self.channel_width_mhz.is_some()
}
pub fn calculate_vortex(&self) -> Option<f64> {
self.try_vortex().ok()
}
pub fn try_vortex(&self) -> Result<f64> {
let down = require(self.down_mbps, "down_mbps")?;
let up = require(self.up_mbps, "up_mbps")?;
let ping = require(self.ping_ms, "ping_ms")?;
let jitter = require(self.jitter_ms, "jitter_ms")?;
let lost = require(self.packet_loss_percent, "packet_loss_percent")?;
ensure_finite_non_negative(down, "down_mbps")?;
ensure_finite_non_negative(up, "up_mbps")?;
ensure_finite_non_negative(ping, "ping_ms")?;
ensure_finite_non_negative(jitter, "jitter_ms")?;
ensure_finite(lost, "packet_loss_percent")?;
if !(0.0..=100.0).contains(&lost) {
return Err(SynapseError::InvalidValue {
field: "packet_loss_percent",
reason: "must be between 0 and 100",
});
}
let down_score = (1.0 + down).log10();
let up_score = (1.0 + up).log10();
let volume = down_score * up_score;
let ping_seconds = ping / 1000.0;
let jitter_seconds = jitter / 1000.0;
let friction = ping_seconds + (Self::JITTER_WEIGHT * jitter_seconds) + Self::EPSILON;
let integrity = (1.0 - (lost / 100.0))
.clamp(0.0, 1.0)
.powf(Self::INTEGRITY_EXPONENT);
let score = (volume / friction) * integrity;
if !score.is_finite() {
return Err(SynapseError::InvalidValue {
field: "vortex",
reason: "calculation produced a non-finite result",
});
}
Ok(score)
}
pub fn calculate_radiance(&self) -> Option<f64> {
self.try_radiance().ok()
}
pub fn try_radiance(&self) -> Result<f64> {
let width = require(self.channel_width_mhz, "channel_width_mhz")?;
let rssi = require(self.rssi_dbm, "rssi_dbm")?;
let noise = require(self.noise_dbm, "noise_dbm")?;
ensure_finite(width, "channel_width_mhz")?;
ensure_finite(rssi, "rssi_dbm")?;
ensure_finite(noise, "noise_dbm")?;
if width <= 0.0 {
return Err(SynapseError::InvalidValue {
field: "channel_width_mhz",
reason: "must be greater than 0",
});
}
if rssi < noise {
return Err(SynapseError::InvalidValue {
field: "rssi_dbm",
reason: "RSSI is below the noise floor",
});
}
let width_factor = width / Self::WIDTH_BASELINE_MHZ;
let snr = rssi - noise;
let score = (width_factor * snr).max(0.0);
if !score.is_finite() {
return Err(SynapseError::InvalidValue {
field: "radiance",
reason: "calculation produced a non-finite result",
});
}
Ok(score)
}
pub fn calculate_axon(&self) -> Option<f64> {
self.try_axon().ok()
}
pub fn try_axon(&self) -> Result<f64> {
let vortex = self.try_vortex()?;
match self.try_radiance() {
Ok(radiance) => {
let score = (vortex * radiance).sqrt();
if !score.is_finite() {
return Err(SynapseError::InvalidValue {
field: "axon",
reason: "calculation produced a non-finite result",
});
}
Ok(score)
}
Err(SynapseError::MissingField(_)) => Ok(vortex),
Err(err) => Err(err),
}
}
}
fn require(value: Option<f64>, field: &'static str) -> Result<f64> {
value.ok_or(SynapseError::MissingField(field))
}
fn ensure_finite(value: f64, field: &'static str) -> Result<()> {
if value.is_finite() {
Ok(())
} else {
Err(SynapseError::InvalidValue {
field,
reason: "must be a finite number",
})
}
}
fn ensure_finite_non_negative(value: f64, field: &'static str) -> Result<()> {
ensure_finite(value, field)?;
if value < 0.0 {
return Err(SynapseError::InvalidValue {
field,
reason: "must be >= 0",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn full_sample() -> NetworkData {
NetworkData::new()
.with_down_mbps(150.0)
.with_up_mbps(40.0)
.with_ping_ms(18.0)
.with_jitter_ms(2.0)
.with_packet_loss_percent(0.0)
.with_rssi_dbm(-60.0)
.with_noise_dbm(-90.0)
.with_channel_width_mhz(40.0)
}
fn wired_sample() -> NetworkData {
NetworkData::new()
.with_down_mbps(45.0)
.with_up_mbps(12.0)
.with_ping_ms(35.0)
.with_jitter_ms(4.0)
.with_packet_loss_percent(0.1)
}
#[test]
fn vortex_happy_path() {
let v = full_sample().try_vortex().unwrap();
assert!(v.is_finite() && v > 0.0);
}
#[test]
fn radiance_happy_path() {
let r = full_sample().try_radiance().unwrap();
assert!((r - 60.0).abs() < 1e-9);
}
#[test]
fn axon_is_geometric_mean_when_wireless() {
let data = full_sample();
let vx = data.try_vortex().unwrap();
let rd = data.try_radiance().unwrap();
let axon = data.try_axon().unwrap();
assert!((axon - (vx * rd).sqrt()).abs() < 1e-9);
}
#[test]
fn axon_falls_back_to_vortex_on_wired() {
let data = wired_sample();
let vx = data.try_vortex().unwrap();
let axon = data.try_axon().unwrap();
assert!((axon - vx).abs() < 1e-12);
assert!(data.calculate_radiance().is_none());
}
#[test]
fn missing_fields_return_none_and_error() {
let data = NetworkData::new();
assert!(data.calculate_vortex().is_none());
assert!(matches!(
data.try_vortex(),
Err(SynapseError::MissingField("down_mbps"))
));
}
#[test]
fn rejects_negative_speeds() {
let data = wired_sample().with_down_mbps(-1.0);
assert!(matches!(
data.try_vortex(),
Err(SynapseError::InvalidValue {
field: "down_mbps",
reason: "must be >= 0"
})
));
assert!(data.calculate_vortex().is_none());
}
#[test]
fn rejects_nan_and_inf() {
let data = wired_sample().with_ping_ms(f64::NAN);
assert!(matches!(
data.try_vortex(),
Err(SynapseError::InvalidValue {
field: "ping_ms",
reason: "must be a finite number"
})
));
let data = wired_sample().with_up_mbps(f64::INFINITY);
assert!(data.try_vortex().is_err());
}
#[test]
fn rejects_packet_loss_out_of_range() {
let data = wired_sample().with_packet_loss_percent(150.0);
assert!(matches!(
data.try_vortex(),
Err(SynapseError::InvalidValue {
field: "packet_loss_percent",
..
})
));
}
#[test]
fn total_packet_loss_zeroes_vortex() {
let data = wired_sample().with_packet_loss_percent(100.0);
let v = data.try_vortex().unwrap();
assert!((v - 0.0).abs() < 1e-12);
}
#[test]
fn rejects_rssi_below_noise() {
let data = NetworkData::new()
.with_rssi_dbm(-100.0)
.with_noise_dbm(-90.0)
.with_channel_width_mhz(20.0);
assert!(matches!(
data.try_radiance(),
Err(SynapseError::InvalidValue {
field: "rssi_dbm",
reason: "RSSI is below the noise floor"
})
));
}
#[test]
fn rejects_zero_channel_width() {
let data = NetworkData::new()
.with_rssi_dbm(-60.0)
.with_noise_dbm(-90.0)
.with_channel_width_mhz(0.0);
assert!(data.try_radiance().is_err());
}
#[test]
fn zero_latency_still_finite_thanks_to_epsilon() {
let data = wired_sample().with_ping_ms(0.0).with_jitter_ms(0.0);
let v = data.try_vortex().unwrap();
assert!(v.is_finite() && v > 0.0);
}
#[test]
fn score_band_thresholds() {
assert_eq!(ScoreBand::from_score(10.0), ScoreBand::Critical);
assert_eq!(ScoreBand::from_score(80.0), ScoreBand::Poor);
assert_eq!(ScoreBand::from_score(200.0), ScoreBand::Fair);
assert_eq!(ScoreBand::from_score(500.0), ScoreBand::Good);
assert_eq!(ScoreBand::from_score(1500.0), ScoreBand::Excellent);
assert_eq!(ScoreBand::from_score(f64::NAN), ScoreBand::Critical);
}
#[test]
fn has_data_helpers() {
assert!(full_sample().has_performance_data());
assert!(full_sample().has_wireless_data());
assert!(wired_sample().has_performance_data());
assert!(!wired_sample().has_wireless_data());
}
#[test]
fn invalid_wireless_does_not_silently_fallback_axon() {
let data = wired_sample()
.with_rssi_dbm(-100.0)
.with_noise_dbm(-90.0)
.with_channel_width_mhz(20.0);
assert!(data.try_axon().is_err());
assert!(data.calculate_axon().is_none());
}
}