1use std::fmt;
15
16#[cfg(feature = "serde")]
17use serde::{Deserialize, Serialize};
18
19use crate::error::{Result, SynapseError};
20
21#[derive(Debug, Clone, Copy, Default, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
35pub struct NetworkData {
36 pub down_mbps: Option<f64>,
38 pub up_mbps: Option<f64>,
40 pub ping_ms: Option<f64>,
42 pub jitter_ms: Option<f64>,
44 pub packet_loss_percent: Option<f64>,
46 pub rssi_dbm: Option<f64>,
48 pub noise_dbm: Option<f64>,
50 pub channel_width_mhz: Option<f64>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
61pub enum ScoreBand {
62 Critical,
64 Poor,
66 Fair,
68 Good,
70 Excellent,
72}
73
74impl ScoreBand {
75 pub fn from_score(score: f64) -> Self {
85 if !score.is_finite() || score < 50.0 {
86 Self::Critical
87 } else if score < 150.0 {
88 Self::Poor
89 } else if score < 400.0 {
90 Self::Fair
91 } else if score < 1000.0 {
92 Self::Good
93 } else {
94 Self::Excellent
95 }
96 }
97
98 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::Critical => "critical",
102 Self::Poor => "poor",
103 Self::Fair => "fair",
104 Self::Good => "good",
105 Self::Excellent => "excellent",
106 }
107 }
108}
109
110impl fmt::Display for ScoreBand {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.write_str(self.as_str())
113 }
114}
115
116impl NetworkData {
117 const EPSILON: f64 = 1e-7;
119
120 const JITTER_WEIGHT: f64 = 3.0;
122
123 const INTEGRITY_EXPONENT: f64 = 10.0;
125
126 const WIDTH_BASELINE_MHZ: f64 = 20.0;
128
129 pub const fn new() -> Self {
131 Self {
132 down_mbps: None,
133 up_mbps: None,
134 ping_ms: None,
135 jitter_ms: None,
136 packet_loss_percent: None,
137 rssi_dbm: None,
138 noise_dbm: None,
139 channel_width_mhz: None,
140 }
141 }
142
143 pub fn with_down_mbps(mut self, v: f64) -> Self {
145 self.down_mbps = Some(v);
146 self
147 }
148
149 pub fn with_up_mbps(mut self, v: f64) -> Self {
151 self.up_mbps = Some(v);
152 self
153 }
154
155 pub fn with_ping_ms(mut self, v: f64) -> Self {
157 self.ping_ms = Some(v);
158 self
159 }
160
161 pub fn with_jitter_ms(mut self, v: f64) -> Self {
163 self.jitter_ms = Some(v);
164 self
165 }
166
167 pub fn with_packet_loss_percent(mut self, v: f64) -> Self {
169 self.packet_loss_percent = Some(v);
170 self
171 }
172
173 pub fn with_rssi_dbm(mut self, v: f64) -> Self {
175 self.rssi_dbm = Some(v);
176 self
177 }
178
179 pub fn with_noise_dbm(mut self, v: f64) -> Self {
181 self.noise_dbm = Some(v);
182 self
183 }
184
185 pub fn with_channel_width_mhz(mut self, v: f64) -> Self {
187 self.channel_width_mhz = Some(v);
188 self
189 }
190
191 pub fn has_performance_data(&self) -> bool {
193 self.down_mbps.is_some()
194 && self.up_mbps.is_some()
195 && self.ping_ms.is_some()
196 && self.jitter_ms.is_some()
197 && self.packet_loss_percent.is_some()
198 }
199
200 pub fn has_wireless_data(&self) -> bool {
202 self.rssi_dbm.is_some() && self.noise_dbm.is_some() && self.channel_width_mhz.is_some()
203 }
204
205 pub fn calculate_vortex(&self) -> Option<f64> {
222 self.try_vortex().ok()
223 }
224
225 pub fn try_vortex(&self) -> Result<f64> {
227 let down = require(self.down_mbps, "down_mbps")?;
228 let up = require(self.up_mbps, "up_mbps")?;
229 let ping = require(self.ping_ms, "ping_ms")?;
230 let jitter = require(self.jitter_ms, "jitter_ms")?;
231 let lost = require(self.packet_loss_percent, "packet_loss_percent")?;
232
233 ensure_finite_non_negative(down, "down_mbps")?;
234 ensure_finite_non_negative(up, "up_mbps")?;
235 ensure_finite_non_negative(ping, "ping_ms")?;
236 ensure_finite_non_negative(jitter, "jitter_ms")?;
237 ensure_finite(lost, "packet_loss_percent")?;
238 if !(0.0..=100.0).contains(&lost) {
239 return Err(SynapseError::InvalidValue {
240 field: "packet_loss_percent",
241 reason: "must be between 0 and 100",
242 });
243 }
244
245 let down_score = (1.0 + down).log10();
246 let up_score = (1.0 + up).log10();
247 let volume = down_score * up_score;
248
249 let ping_seconds = ping / 1000.0;
250 let jitter_seconds = jitter / 1000.0;
251 let friction = ping_seconds + (Self::JITTER_WEIGHT * jitter_seconds) + Self::EPSILON;
252
253 let integrity = (1.0 - (lost / 100.0))
254 .clamp(0.0, 1.0)
255 .powf(Self::INTEGRITY_EXPONENT);
256
257 let score = (volume / friction) * integrity;
258 if !score.is_finite() {
259 return Err(SynapseError::InvalidValue {
260 field: "vortex",
261 reason: "calculation produced a non-finite result",
262 });
263 }
264 Ok(score)
265 }
266
267 pub fn calculate_radiance(&self) -> Option<f64> {
279 self.try_radiance().ok()
280 }
281
282 pub fn try_radiance(&self) -> Result<f64> {
284 let width = require(self.channel_width_mhz, "channel_width_mhz")?;
285 let rssi = require(self.rssi_dbm, "rssi_dbm")?;
286 let noise = require(self.noise_dbm, "noise_dbm")?;
287
288 ensure_finite(width, "channel_width_mhz")?;
289 ensure_finite(rssi, "rssi_dbm")?;
290 ensure_finite(noise, "noise_dbm")?;
291
292 if width <= 0.0 {
293 return Err(SynapseError::InvalidValue {
294 field: "channel_width_mhz",
295 reason: "must be greater than 0",
296 });
297 }
298 if rssi < noise {
300 return Err(SynapseError::InvalidValue {
301 field: "rssi_dbm",
302 reason: "RSSI is below the noise floor",
303 });
304 }
305
306 let width_factor = width / Self::WIDTH_BASELINE_MHZ;
307 let snr = rssi - noise;
308 let score = (width_factor * snr).max(0.0);
309
310 if !score.is_finite() {
311 return Err(SynapseError::InvalidValue {
312 field: "radiance",
313 reason: "calculation produced a non-finite result",
314 });
315 }
316 Ok(score)
317 }
318
319 pub fn calculate_axon(&self) -> Option<f64> {
331 self.try_axon().ok()
332 }
333
334 pub fn try_axon(&self) -> Result<f64> {
336 let vortex = self.try_vortex()?;
337
338 match self.try_radiance() {
339 Ok(radiance) => {
340 let score = (vortex * radiance).sqrt();
341 if !score.is_finite() {
342 return Err(SynapseError::InvalidValue {
343 field: "axon",
344 reason: "calculation produced a non-finite result",
345 });
346 }
347 Ok(score)
348 }
349 Err(SynapseError::MissingField(_)) => Ok(vortex),
351 Err(err) => Err(err),
352 }
353 }
354}
355
356fn require(value: Option<f64>, field: &'static str) -> Result<f64> {
357 value.ok_or(SynapseError::MissingField(field))
358}
359
360fn ensure_finite(value: f64, field: &'static str) -> Result<()> {
361 if value.is_finite() {
362 Ok(())
363 } else {
364 Err(SynapseError::InvalidValue {
365 field,
366 reason: "must be a finite number",
367 })
368 }
369}
370
371fn ensure_finite_non_negative(value: f64, field: &'static str) -> Result<()> {
372 ensure_finite(value, field)?;
373 if value < 0.0 {
374 return Err(SynapseError::InvalidValue {
375 field,
376 reason: "must be >= 0",
377 });
378 }
379 Ok(())
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn full_sample() -> NetworkData {
387 NetworkData::new()
388 .with_down_mbps(150.0)
389 .with_up_mbps(40.0)
390 .with_ping_ms(18.0)
391 .with_jitter_ms(2.0)
392 .with_packet_loss_percent(0.0)
393 .with_rssi_dbm(-60.0)
394 .with_noise_dbm(-90.0)
395 .with_channel_width_mhz(40.0)
396 }
397
398 fn wired_sample() -> NetworkData {
399 NetworkData::new()
400 .with_down_mbps(45.0)
401 .with_up_mbps(12.0)
402 .with_ping_ms(35.0)
403 .with_jitter_ms(4.0)
404 .with_packet_loss_percent(0.1)
405 }
406
407 #[test]
408 fn vortex_happy_path() {
409 let v = full_sample().try_vortex().unwrap();
410 assert!(v.is_finite() && v > 0.0);
411 }
412
413 #[test]
414 fn radiance_happy_path() {
415 let r = full_sample().try_radiance().unwrap();
417 assert!((r - 60.0).abs() < 1e-9);
418 }
419
420 #[test]
421 fn axon_is_geometric_mean_when_wireless() {
422 let data = full_sample();
423 let vx = data.try_vortex().unwrap();
424 let rd = data.try_radiance().unwrap();
425 let axon = data.try_axon().unwrap();
426 assert!((axon - (vx * rd).sqrt()).abs() < 1e-9);
427 }
428
429 #[test]
430 fn axon_falls_back_to_vortex_on_wired() {
431 let data = wired_sample();
432 let vx = data.try_vortex().unwrap();
433 let axon = data.try_axon().unwrap();
434 assert!((axon - vx).abs() < 1e-12);
435 assert!(data.calculate_radiance().is_none());
436 }
437
438 #[test]
439 fn missing_fields_return_none_and_error() {
440 let data = NetworkData::new();
441 assert!(data.calculate_vortex().is_none());
442 assert!(matches!(
443 data.try_vortex(),
444 Err(SynapseError::MissingField("down_mbps"))
445 ));
446 }
447
448 #[test]
449 fn rejects_negative_speeds() {
450 let data = wired_sample().with_down_mbps(-1.0);
451 assert!(matches!(
452 data.try_vortex(),
453 Err(SynapseError::InvalidValue {
454 field: "down_mbps",
455 reason: "must be >= 0"
456 })
457 ));
458 assert!(data.calculate_vortex().is_none());
459 }
460
461 #[test]
462 fn rejects_nan_and_inf() {
463 let data = wired_sample().with_ping_ms(f64::NAN);
464 assert!(matches!(
465 data.try_vortex(),
466 Err(SynapseError::InvalidValue {
467 field: "ping_ms",
468 reason: "must be a finite number"
469 })
470 ));
471
472 let data = wired_sample().with_up_mbps(f64::INFINITY);
473 assert!(data.try_vortex().is_err());
474 }
475
476 #[test]
477 fn rejects_packet_loss_out_of_range() {
478 let data = wired_sample().with_packet_loss_percent(150.0);
479 assert!(matches!(
480 data.try_vortex(),
481 Err(SynapseError::InvalidValue {
482 field: "packet_loss_percent",
483 ..
484 })
485 ));
486 }
487
488 #[test]
489 fn total_packet_loss_zeroes_vortex() {
490 let data = wired_sample().with_packet_loss_percent(100.0);
491 let v = data.try_vortex().unwrap();
492 assert!((v - 0.0).abs() < 1e-12);
493 }
494
495 #[test]
496 fn rejects_rssi_below_noise() {
497 let data = NetworkData::new()
498 .with_rssi_dbm(-100.0)
499 .with_noise_dbm(-90.0)
500 .with_channel_width_mhz(20.0);
501 assert!(matches!(
502 data.try_radiance(),
503 Err(SynapseError::InvalidValue {
504 field: "rssi_dbm",
505 reason: "RSSI is below the noise floor"
506 })
507 ));
508 }
509
510 #[test]
511 fn rejects_zero_channel_width() {
512 let data = NetworkData::new()
513 .with_rssi_dbm(-60.0)
514 .with_noise_dbm(-90.0)
515 .with_channel_width_mhz(0.0);
516 assert!(data.try_radiance().is_err());
517 }
518
519 #[test]
520 fn zero_latency_still_finite_thanks_to_epsilon() {
521 let data = wired_sample().with_ping_ms(0.0).with_jitter_ms(0.0);
522 let v = data.try_vortex().unwrap();
523 assert!(v.is_finite() && v > 0.0);
524 }
525
526 #[test]
527 fn score_band_thresholds() {
528 assert_eq!(ScoreBand::from_score(10.0), ScoreBand::Critical);
529 assert_eq!(ScoreBand::from_score(80.0), ScoreBand::Poor);
530 assert_eq!(ScoreBand::from_score(200.0), ScoreBand::Fair);
531 assert_eq!(ScoreBand::from_score(500.0), ScoreBand::Good);
532 assert_eq!(ScoreBand::from_score(1500.0), ScoreBand::Excellent);
533 assert_eq!(ScoreBand::from_score(f64::NAN), ScoreBand::Critical);
534 }
535
536 #[test]
537 fn has_data_helpers() {
538 assert!(full_sample().has_performance_data());
539 assert!(full_sample().has_wireless_data());
540 assert!(wired_sample().has_performance_data());
541 assert!(!wired_sample().has_wireless_data());
542 }
543
544 #[test]
545 fn invalid_wireless_does_not_silently_fallback_axon() {
546 let data = wired_sample()
549 .with_rssi_dbm(-100.0)
550 .with_noise_dbm(-90.0)
551 .with_channel_width_mhz(20.0);
552 assert!(data.try_axon().is_err());
553 assert!(data.calculate_axon().is_none());
554 }
555}