Skip to main content

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