wlancfg_lib/client/connection_selection/
scoring_functions.rs

1// Copyright 2023 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use std::cmp::max;
6
7use crate::client::types;
8use crate::config_management::FailureReason::CredentialRejected;
9use crate::util::pseudo_energy::*;
10
11/// Weighting constants
12const RSSI_AND_VELOCITY_SCORE_WEIGHT: f32 = 0.6;
13const SNR_SCORE_WEIGHT: f32 = 0.4;
14
15/// 5GHz score bonus constants
16const LOWER_RSSI_BOUND_FOR_5G_BONUS: i16 = -64; // Bonus tapers below this RSSI
17const UPPER_RSSI_BOUND_FOR_5G_BONUS: i16 = -25; // Bonus tapers above this RSSI
18const MAX_5G_PREFERENCE_BOOST: i16 = 20;
19const TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE: i16 = 2;
20
21/// Score penalty constants
22const SCORE_PENALTY_FOR_RECENT_CONNECT_FAILURE: i16 = 5;
23const THRESHOLD_EXCESSIVE_RECENT_CONNECT_FAILURES: usize = 5; // Excessive failures warrant higher penalty
24const SCORE_PENALTY_FOR_EXCESSIVE_RECENT_CONNECT_FAILURES: i16 = 10;
25const SCORE_PENALTY_FOR_RECENT_CREDENTIAL_REJECTED: i16 = 30; // Higher penalty, since future success is unlikely
26const SCORE_PENALTY_FOR_SHORT_CONNECTION: i16 = 20;
27
28pub fn score_bss_scanned_candidate(bss_candidate: types::ScannedCandidate) -> i16 {
29    let mut score = calculate_base_signal_score(bss_candidate.bss.signal.rssi_dbm as i16);
30    let channel = bss_candidate.bss.channel;
31
32    // If the network is 5G and has a strong enough RSSI, give it a bonus.
33    if channel.is_5ghz() {
34        score = score.saturating_add(calculate_5g_bonus(score));
35    }
36
37    // Penalize APs with recent failures to connect
38    let matching_failures = bss_candidate
39        .saved_network_info
40        .recent_failures
41        .iter()
42        .filter(|failure| failure.bssid == bss_candidate.bss.bssid);
43    let mut connect_failure_count: usize = 0;
44    for failure in matching_failures {
45        // Count failures for rejected credentials higher since we probably won't succeed
46        // another try with the same credentials.
47        if failure.reason == CredentialRejected {
48            score = score.saturating_sub(SCORE_PENALTY_FOR_RECENT_CREDENTIAL_REJECTED);
49        } else {
50            connect_failure_count += 1;
51            if connect_failure_count <= THRESHOLD_EXCESSIVE_RECENT_CONNECT_FAILURES {
52                score = score.saturating_sub(SCORE_PENALTY_FOR_RECENT_CONNECT_FAILURE);
53            } else {
54                // Additional penalty for excessive recent failures.
55                score = score.saturating_sub(SCORE_PENALTY_FOR_EXCESSIVE_RECENT_CONNECT_FAILURES);
56            }
57        }
58    }
59    // Penalize APs with recent short connections before disconnecting.
60    let short_connection_score: i16 = bss_candidate
61        .recent_short_connections()
62        .try_into()
63        .unwrap_or(i16::MAX)
64        .saturating_mul(SCORE_PENALTY_FOR_SHORT_CONNECTION);
65
66    score.saturating_sub(short_connection_score)
67}
68
69/// Scores are based on RSSI, before any bonuses or penalties are applied, using a piecewise linear
70/// (y=mx+b) function. As signal strength increases beyond -30 dBm, connectivity gets progressively
71/// worse due to RF receiver saturation, increased noise, etc. For signals > -30 dBm, we linearly
72/// taper off the signal-based score.
73///   - For RSSI <= -30, score == RSSI.
74///   - For RSSI > -30, score == -2.7735 * RSSI - 113.2 (based on go/fuchsia-wlan:penalizing-high-rssi)
75fn calculate_base_signal_score(rssi: i16) -> i16 {
76    if rssi <= -30 {
77        rssi
78    } else {
79        let m = -2.7735;
80        let b = -113.2;
81        let y = m * rssi as f64 + b;
82        y as i16
83    }
84}
85
86fn calculate_5g_bonus(rssi: i16) -> i16 {
87    // Determine "distance" (in dBm) the RSSI falls outside of the bonus range.
88    let taper_rate = max(
89        max(LOWER_RSSI_BOUND_FOR_5G_BONUS - rssi, 0),
90        max(rssi - UPPER_RSSI_BOUND_FOR_5G_BONUS, 0),
91    );
92    // For each dBm outside bonus range, reduce bonus by the taper amount, down to a minimum of 0.
93    max(0, MAX_5G_PREFERENCE_BOOST - (taper_rate * TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE))
94}
95
96pub fn score_current_connection_signal_data(
97    data: EwmaSignalData,
98    rssi_velocity: impl Into<f64> + std::cmp::PartialOrd<f64>,
99) -> u8 {
100    let rssi_velocity_score = match data.ewma_rssi.get() {
101        r if r <= -81.0 => match rssi_velocity {
102            v if v < -2.7 => 0,
103            v if v < -1.8 => 0,
104            v if v < -0.9 => 0,
105            v if v <= 0.9 => 0,
106            v if v <= 1.8 => 20,
107            v if v <= 2.7 => 18,
108            _ => 10,
109        },
110        r if r <= -76.0 => match rssi_velocity {
111            v if v < -2.7 => 0,
112            v if v < -1.8 => 0,
113            v if v < -0.9 => 0,
114            v if v <= 0.9 => 15,
115            v if v <= 1.8 => 28,
116            v if v <= 2.7 => 25,
117            _ => 15,
118        },
119        r if r <= -71.0 => match rssi_velocity {
120            v if v < -2.7 => 0,
121            v if v < -1.8 => 5,
122            v if v < -0.9 => 15,
123            v if v <= 0.9 => 30,
124            v if v <= 1.8 => 45,
125            v if v <= 2.7 => 38,
126            _ => 4,
127        },
128        r if r <= -66.0 => match rssi_velocity {
129            v if v < -2.7 => 10,
130            v if v < -1.8 => 18,
131            v if v < -0.9 => 30,
132            v if v <= 0.9 => 48,
133            v if v <= 1.8 => 60,
134            v if v <= 2.7 => 50,
135            _ => 38,
136        },
137        r if r <= -61.0 => match rssi_velocity {
138            v if v < -2.7 => 20,
139            v if v < -1.8 => 30,
140            v if v < -0.9 => 45,
141            v if v <= 0.9 => 70,
142            v if v <= 1.8 => 75,
143            v if v <= 2.7 => 60,
144            _ => 55,
145        },
146        r if r <= -56.0 => match rssi_velocity {
147            v if v < -2.7 => 40,
148            v if v < -1.8 => 50,
149            v if v < -0.9 => 63,
150            v if v <= 0.9 => 85,
151            v if v <= 1.8 => 85,
152            v if v <= 2.7 => 70,
153            _ => 65,
154        },
155        r if r <= -51.0 => match rssi_velocity {
156            v if v < -2.7 => 55,
157            v if v < -1.8 => 65,
158            v if v < -0.9 => 75,
159            v if v <= 0.9 => 95,
160            v if v <= 1.8 => 90,
161            v if v <= 2.7 => 80,
162            _ => 75,
163        },
164        _ => match rssi_velocity {
165            v if v < -2.7 => 60,
166            v if v < -1.8 => 70,
167            v if v < -0.9 => 80,
168            v if v <= 0.9 => 100,
169            v if v <= 1.8 => 95,
170            v if v <= 2.7 => 90,
171            _ => 80,
172        },
173    };
174
175    let snr_score = match data.ewma_snr.get() {
176        s if s <= 10.0 => 0,
177        s if s <= 15.0 => 15,
178        s if s <= 20.0 => 37,
179        s if s <= 25.0 => 53,
180        s if s <= 30.0 => 68,
181        s if s <= 35.0 => 80,
182        s if s <= 40.0 => 95,
183        _ => 100,
184    };
185
186    ((rssi_velocity_score as f32 * RSSI_AND_VELOCITY_SCORE_WEIGHT)
187        + (snr_score as f32 * SNR_SCORE_WEIGHT)) as u8
188}
189
190#[cfg(test)]
191mod test {
192    use super::*;
193    use crate::config_management::{ConnectFailure, FailureReason, PastConnectionData};
194    use crate::util::testing::{
195        generate_channel, generate_random_bss, generate_random_saved_network_data,
196        generate_random_scanned_candidate, random_connection_data,
197    };
198    use fuchsia_async as fasync;
199    use test_util::assert_gt;
200
201    fn connect_failure_with_bssid(bssid: types::Bssid) -> ConnectFailure {
202        ConnectFailure {
203            reason: FailureReason::GeneralFailure,
204            time: fasync::MonotonicInstant::INFINITE,
205            bssid,
206        }
207    }
208
209    fn past_connection_with_bssid_uptime(
210        bssid: types::Bssid,
211        uptime: zx::MonotonicDuration,
212    ) -> PastConnectionData {
213        PastConnectionData {
214            bssid,
215            connection_uptime: uptime,
216            disconnect_time: fasync::MonotonicInstant::INFINITE, // disconnect will always be considered recent
217            ..random_connection_data()
218        }
219    }
220
221    #[fuchsia::test]
222    fn test_weights_sum_to_one() {
223        assert_eq!(RSSI_AND_VELOCITY_SCORE_WEIGHT + SNR_SCORE_WEIGHT, 1.0);
224    }
225
226    #[fasync::run_singlethreaded(test)]
227    async fn test_score_bss_prefers_less_short_connections() {
228        let bss_worse = types::Bss {
229            signal: types::Signal { rssi_dbm: -60, snr_db: 0 },
230            channel: generate_channel(3),
231            ..generate_random_bss()
232        };
233        let bss_better = types::Bss {
234            signal: types::Signal { rssi_dbm: -60, snr_db: 0 },
235            channel: generate_channel(3),
236            ..generate_random_bss()
237        };
238        let mut internal_data = generate_random_saved_network_data();
239        let short_uptime = zx::MonotonicDuration::from_seconds(30);
240        let okay_uptime = zx::MonotonicDuration::from_minutes(100);
241        // Record a short uptime for the worse network and a long enough uptime for the better one.
242        let short_uptime_data = past_connection_with_bssid_uptime(bss_worse.bssid, short_uptime);
243        let okay_uptime_data = past_connection_with_bssid_uptime(bss_better.bssid, okay_uptime);
244        internal_data.past_connections.add(bss_worse.bssid, short_uptime_data);
245        internal_data.past_connections.add(bss_better.bssid, okay_uptime_data);
246        let shared_candidate_data = types::ScannedCandidate {
247            saved_network_info: internal_data,
248            ..generate_random_scanned_candidate()
249        };
250        let bss_worse = types::ScannedCandidate { bss: bss_worse, ..shared_candidate_data.clone() };
251        let bss_better =
252            types::ScannedCandidate { bss: bss_better, ..shared_candidate_data.clone() };
253
254        // Check that the better BSS has a higher score than the worse BSS.
255        assert!(score_bss_scanned_candidate(bss_better) > score_bss_scanned_candidate(bss_worse));
256    }
257
258    #[fasync::run_singlethreaded(test)]
259    async fn test_score_bss_prefers_less_failures() {
260        let bss_worse = types::Bss {
261            signal: types::Signal { rssi_dbm: -60, snr_db: 0 },
262            channel: generate_channel(3),
263            ..generate_random_bss()
264        };
265        let bss_better = types::Bss {
266            signal: types::Signal { rssi_dbm: -60, snr_db: 0 },
267            channel: generate_channel(3),
268            ..generate_random_bss()
269        };
270        let mut internal_data = generate_random_saved_network_data();
271        // Add many test failures for the worse BSS and one for the better BSS
272        let mut failures = vec![connect_failure_with_bssid(bss_worse.bssid); 12];
273        failures.push(connect_failure_with_bssid(bss_better.bssid));
274        internal_data.recent_failures = failures;
275        let shared_candidate_data = types::ScannedCandidate {
276            saved_network_info: internal_data,
277            ..generate_random_scanned_candidate()
278        };
279        let bss_worse = types::ScannedCandidate { bss: bss_worse, ..shared_candidate_data.clone() };
280        let bss_better =
281            types::ScannedCandidate { bss: bss_better, ..shared_candidate_data.clone() };
282        // Check that the better BSS has a higher score than the worse BSS.
283        assert!(score_bss_scanned_candidate(bss_better) > score_bss_scanned_candidate(bss_worse));
284    }
285
286    #[fasync::run_singlethreaded(test)]
287    async fn test_score_bss_prefers_strong_5ghz_with_failures() {
288        // Test test that if one network has a few network failures but is 5 Ghz instead of 2.4,
289        // the 5 GHz network has a higher score.
290        let bss_worse = types::Bss {
291            signal: types::Signal { rssi_dbm: -35, snr_db: 0 },
292            channel: generate_channel(3),
293            ..generate_random_bss()
294        };
295        let bss_better = types::Bss {
296            signal: types::Signal { rssi_dbm: -35, snr_db: 0 },
297            channel: generate_channel(36),
298            ..generate_random_bss()
299        };
300        let mut internal_data = generate_random_saved_network_data();
301        // Set the failure list to have 0 failures for the worse BSS and 4 failures for the
302        // stronger BSS.
303        internal_data.recent_failures = vec![connect_failure_with_bssid(bss_better.bssid); 2];
304        let shared_candidate_data = types::ScannedCandidate {
305            saved_network_info: internal_data,
306            ..generate_random_scanned_candidate()
307        };
308        let bss_worse = types::ScannedCandidate { bss: bss_worse, ..shared_candidate_data.clone() };
309        let bss_better =
310            types::ScannedCandidate { bss: bss_better, ..shared_candidate_data.clone() };
311        assert!(score_bss_scanned_candidate(bss_better) > score_bss_scanned_candidate(bss_worse));
312    }
313
314    #[fasync::run_singlethreaded(test)]
315    async fn test_score_credentials_rejected_worse() {
316        // If two BSS are identical other than one failed to connect with wrong credentials and
317        // the other failed with a few connect failurs, the one with wrong credentials has a lower
318        // score.
319        let bss_worse = types::Bss {
320            signal: types::Signal { rssi_dbm: -30, snr_db: 0 },
321            channel: generate_channel(44),
322            ..generate_random_bss()
323        };
324        let bss_better = types::Bss {
325            signal: types::Signal { rssi_dbm: -30, snr_db: 0 },
326            channel: generate_channel(44),
327            ..generate_random_bss()
328        };
329        let mut internal_data = generate_random_saved_network_data();
330        // Add many test failures for the worse BSS and one for the better BSS
331        let mut failures = vec![connect_failure_with_bssid(bss_better.bssid); 4];
332        failures.push(ConnectFailure {
333            bssid: bss_worse.bssid,
334            time: fasync::MonotonicInstant::now(),
335            reason: FailureReason::CredentialRejected,
336        });
337        internal_data.recent_failures = failures;
338        let shared_candidate_data = types::ScannedCandidate {
339            saved_network_info: internal_data,
340            ..generate_random_scanned_candidate()
341        };
342        let bss_worse = types::ScannedCandidate { bss: bss_worse, ..shared_candidate_data.clone() };
343        let bss_better =
344            types::ScannedCandidate { bss: bss_better, ..shared_candidate_data.clone() };
345
346        assert!(score_bss_scanned_candidate(bss_better) > score_bss_scanned_candidate(bss_worse));
347    }
348
349    #[fasync::run_singlethreaded(test)]
350    async fn score_many_penalties_do_not_cause_panic() {
351        let bss = types::Bss {
352            signal: types::Signal { rssi_dbm: -80, snr_db: 0 },
353            channel: generate_channel(1),
354            ..generate_random_bss()
355        };
356        let mut internal_data = generate_random_saved_network_data();
357        // Add 10 general failures and 10 rejected credentials failures
358        internal_data.recent_failures = vec![connect_failure_with_bssid(bss.bssid); 10];
359        for _ in 0..1200 {
360            internal_data.recent_failures.push(ConnectFailure {
361                bssid: bss.bssid,
362                time: fasync::MonotonicInstant::now(),
363                reason: FailureReason::CredentialRejected,
364            });
365        }
366        let short_uptime = zx::MonotonicDuration::from_seconds(30);
367        let data = past_connection_with_bssid_uptime(bss.bssid, short_uptime);
368        for _ in 0..10 {
369            internal_data.past_connections.add(bss.bssid, data);
370        }
371        let scanned_candidate = types::ScannedCandidate {
372            bss,
373            saved_network_info: internal_data,
374            ..generate_random_scanned_candidate()
375        };
376
377        assert_eq!(score_bss_scanned_candidate(scanned_candidate), i16::MIN);
378    }
379
380    // Trivial scoring algorithm test cases. Should pass (or be removed with acknowledgment) for
381    // any scoring algorithm implementation.
382    #[fuchsia::test]
383    fn high_rssi_scores_higher_than_low_rssi() {
384        let strong_clear_signal = EwmaSignalData::new(-50, 35, 10);
385        let weak_clear_signal = EwmaSignalData::new(-85, 35, 10);
386        assert_gt!(
387            score_current_connection_signal_data(strong_clear_signal, 0.0),
388            score_current_connection_signal_data(weak_clear_signal, 0.0)
389        );
390
391        let strong_noisy_signal = EwmaSignalData::new(-50, 5, 10);
392        let weak_noisy_signal = EwmaSignalData::new(-85, 55, 10);
393        assert_gt!(
394            score_current_connection_signal_data(strong_noisy_signal, 0.0),
395            score_current_connection_signal_data(weak_noisy_signal, 0.0)
396        );
397    }
398
399    #[fuchsia::test]
400    fn high_snr_scores_higher_than_low_snr() {
401        let strong_clear_signal = EwmaSignalData::new(-50, 35, 10);
402        let strong_noisy_signal = EwmaSignalData::new(-50, 5, 10);
403        assert_gt!(
404            score_current_connection_signal_data(strong_clear_signal, 0.0),
405            score_current_connection_signal_data(strong_noisy_signal, 0.0)
406        );
407
408        let weak_clear_signal = EwmaSignalData::new(-85, 35, 10);
409        let weak_noisy_signal = EwmaSignalData::new(-85, 5, 10);
410        assert_gt!(
411            score_current_connection_signal_data(weak_clear_signal, 0.0),
412            score_current_connection_signal_data(weak_noisy_signal, 0.0)
413        );
414    }
415
416    #[fuchsia::test]
417    fn positive_velocity_scores_higher_than_negative_velocity() {
418        let signal = EwmaSignalData::new(-50, 35, 10);
419        assert_gt!(
420            score_current_connection_signal_data(signal, 3.0),
421            score_current_connection_signal_data(signal, -3.0)
422        );
423    }
424
425    #[fuchsia::test]
426    fn stable_high_rssi_scores_higher_than_volatile_high_rssi() {
427        let strong_signal = EwmaSignalData::new(-50, 35, 10);
428        assert_gt!(
429            score_current_connection_signal_data(strong_signal, 0.0),
430            score_current_connection_signal_data(strong_signal, 3.0)
431        );
432        assert_gt!(
433            score_current_connection_signal_data(strong_signal, 0.0),
434            score_current_connection_signal_data(strong_signal, -3.0)
435        );
436    }
437
438    #[fuchsia::test]
439    fn improving_weak_rssi_scores_higher_than_stable_weak_rssi() {
440        let weak_signal = EwmaSignalData::new(-85, 10, 10);
441        assert_gt!(
442            score_current_connection_signal_data(weak_signal, 3.0),
443            score_current_connection_signal_data(weak_signal, 0.0)
444        );
445    }
446
447    #[fuchsia::test]
448    fn test_calculate_5g_bonus_max_bonus_between_cutoffs() {
449        assert_eq!(calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS), MAX_5G_PREFERENCE_BOOST);
450        assert_eq!(calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS + 1), MAX_5G_PREFERENCE_BOOST);
451        assert_eq!(calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS - 1), MAX_5G_PREFERENCE_BOOST);
452        assert_eq!(calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS), MAX_5G_PREFERENCE_BOOST);
453    }
454
455    #[fuchsia::test]
456    fn test_calculate_5g_bonus_linear_decrease_below_lower_cutoff() {
457        assert_eq!(
458            calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS - 1),
459            MAX_5G_PREFERENCE_BOOST - TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE
460        );
461        assert_eq!(
462            calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS - 2),
463            MAX_5G_PREFERENCE_BOOST - (2 * TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE)
464        );
465        assert_eq!(calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS - 10), 0);
466        assert_eq!(calculate_5g_bonus(LOWER_RSSI_BOUND_FOR_5G_BONUS - 20), 0);
467    }
468
469    #[fuchsia::test]
470    fn test_calculate_5g_bonus_linear_decrease_above_upper_cutoff() {
471        assert_eq!(
472            calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS + 1),
473            MAX_5G_PREFERENCE_BOOST - TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE
474        );
475        assert_eq!(
476            calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS + 2),
477            MAX_5G_PREFERENCE_BOOST - (2 * TAPER_AMOUNT_FOR_5G_BONUS_PER_DBM_OUTSIDE_RANGE)
478        );
479        assert_eq!(calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS + 10), 0);
480        assert_eq!(calculate_5g_bonus(UPPER_RSSI_BOUND_FOR_5G_BONUS + 20), 0);
481    }
482
483    #[fuchsia::test]
484    fn test_calculate_base_signal_score() {
485        // For RSSI <= -30, score == RSSI
486        assert_eq!(calculate_base_signal_score(-30), -30);
487        assert_eq!(calculate_base_signal_score(-50), -50);
488
489        // For RSSI > -30, score is follows a negative slope line.
490        assert_eq!(calculate_base_signal_score(-25), -43);
491        assert_eq!(calculate_base_signal_score(-20), -57);
492    }
493}