Skip to main content

poolsim_core/
erlang.rs

1//! Erlang-C queueing helpers used by sizing and sensitivity calculations.
2//!
3//! This module is the analytical queueing side of `poolsim-core`.
4//! It is primarily used when the queue model is M/M/c and provides:
5//!
6//! - utilisation (`rho`)
7//! - wait probability
8//! - mean queue wait
9//! - queue-wait percentiles
10//!
11//! When the queue model is M/D/c, the crate falls back to Monte Carlo probing
12//! for the quantities that do not have a direct closed-form implementation here.
13
14use crate::error::PoolsimError;
15
16/// Computes utilization (`rho = lambda / (c * mu)`).
17pub fn utilisation(lambda: f64, mu: f64, c: u32) -> f64 {
18    if c == 0 || mu <= 0.0 {
19        return f64::INFINITY;
20    }
21    lambda / (c as f64 * mu)
22}
23
24/// Computes Erlang-C waiting probability for an M/M/c queue.
25///
26/// # Errors
27///
28/// Returns [`PoolsimError::InvalidInput`] when `c == 0` or `mu <= 0`,
29/// and [`PoolsimError::Saturated`] when `rho >= 1.0`.
30pub fn erlang_c(lambda: f64, mu: f64, c: u32) -> Result<f64, PoolsimError> {
31    if c == 0 {
32        return Err(PoolsimError::invalid_input(
33            "INVALID_SERVER_COUNT",
34            "server count must be > 0",
35            None,
36        ));
37    }
38    if mu <= 0.0 {
39        return Err(PoolsimError::invalid_input(
40            "INVALID_SERVICE_RATE",
41            "service rate must be > 0",
42            None,
43        ));
44    }
45    if lambda <= 0.0 {
46        return Ok(0.0);
47    }
48
49    let rho = utilisation(lambda, mu, c);
50    if rho >= 1.0 {
51        return Err(PoolsimError::Saturated { rho });
52    }
53
54    let offered_load = lambda / mu;
55    let mut sum = 1.0;
56    let mut term = 1.0;
57
58    for k in 1..c {
59        term *= offered_load / k as f64;
60        sum += term;
61    }
62
63    let term_c = term * offered_load / c as f64;
64    let top = term_c / (1.0 - rho);
65    Ok(top / (sum + top))
66}
67
68/// Computes mean queue wait (milliseconds) for an M/M/c queue.
69///
70/// # Errors
71///
72/// Returns the same errors as [`erlang_c`] and saturated errors when the
73/// denominator term becomes non-positive.
74pub fn mean_queue_wait_ms(lambda: f64, mu: f64, c: u32) -> Result<f64, PoolsimError> {
75    if lambda <= 0.0 {
76        return Ok(0.0);
77    }
78
79    let p_wait = erlang_c(lambda, mu, c)?;
80    let denom = c as f64 * mu - lambda;
81    if !denom.is_finite() || denom <= 0.0 {
82        return Err(PoolsimError::Saturated {
83            rho: utilisation(lambda, mu, c),
84        });
85    }
86
87    Ok((p_wait / denom) * 1_000.0)
88}
89
90/// Computes queue-wait percentile (milliseconds) for an M/M/c queue.
91///
92/// `quantile` is clamped into `[0, 1]`.
93///
94/// # Errors
95///
96/// Returns the same errors as [`erlang_c`] and saturated errors when the
97/// tail rate becomes non-positive.
98pub fn queue_wait_percentile_ms(lambda: f64, mu: f64, c: u32, quantile: f64) -> Result<f64, PoolsimError> {
99    if lambda <= 0.0 {
100        return Ok(0.0);
101    }
102
103    let q = quantile.clamp(0.0, 1.0);
104    if q == 0.0 {
105        return Ok(0.0);
106    }
107
108    let p_wait = erlang_c(lambda, mu, c)?;
109    if q <= 1.0 - p_wait {
110        return Ok(0.0);
111    }
112
113    let rate = c as f64 * mu - lambda;
114    if !rate.is_finite() || rate <= 0.0 {
115        return Err(PoolsimError::Saturated {
116            rho: utilisation(lambda, mu, c),
117        });
118    }
119
120    let tail = ((1.0 - q) / p_wait).max(f64::MIN_POSITIVE);
121    Ok((-tail.ln() / rate) * 1_000.0)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn erlang_c_known_case() {
130        let c = 10;
131        let mu = 1.0;
132        let lambda = 8.0;
133        let p_wait = erlang_c(lambda, mu, c).expect("valid erlang c");
134        assert!((p_wait - 0.40918).abs() < 0.005);
135    }
136
137    #[test]
138    fn erlang_c_reference_matrix() {
139        let mu = 1.0;
140        let cases = [
141            (2, 0.5, 0.33333333),
142            (2, 0.8, 0.71111111),
143            (2, 0.9, 0.85263158),
144            (3, 0.7, 0.49234450),
145            (3, 0.9, 0.81706102),
146            (4, 0.5, 0.17391304),
147            (4, 0.8, 0.59643247),
148            (4, 0.95, 0.89141900),
149            (5, 0.7, 0.37783823),
150            (5, 0.9, 0.76249322),
151            (6, 0.8, 0.51777200),
152            (6, 0.95, 0.86558880),
153            (8, 0.7, 0.27060293),
154            (8, 0.9, 0.70153299),
155            (10, 0.8, 0.40918015),
156            (10, 0.95, 0.82558558),
157            (12, 0.7, 0.18388863),
158            (12, 0.9, 0.64004291),
159            (16, 0.8, 0.30488391),
160            (20, 0.9, 0.55076900),
161        ];
162
163        for (c, rho, expected) in cases {
164            let lambda = rho * c as f64 * mu;
165            let actual = erlang_c(lambda, mu, c).expect("reference case should be valid");
166            assert!(
167                (actual - expected).abs() < 1e-6,
168                "c={c}, rho={rho}, expected={expected}, actual={actual}"
169            );
170        }
171    }
172
173    #[test]
174    fn mean_queue_wait_increases_as_utilisation_rises() {
175        let c = 8;
176        let mu = 1.0;
177        let low = mean_queue_wait_ms(0.5 * c as f64 * mu, mu, c).expect("low utilisation should work");
178        let high = mean_queue_wait_ms(0.9 * c as f64 * mu, mu, c).expect("high utilisation should work");
179        assert!(high > low);
180    }
181
182    #[test]
183    fn queue_percentile_is_zero_when_quantile_in_non_waiting_mass() {
184        let c = 4;
185        let mu = 1.0;
186        let lambda = 0.5 * c as f64 * mu;
187        let p_wait = erlang_c(lambda, mu, c).expect("valid erlang c");
188        let threshold = 1.0 - p_wait;
189        let q = threshold * 0.99;
190        let value = queue_wait_percentile_ms(lambda, mu, c, q).expect("valid percentile");
191        assert_eq!(value, 0.0);
192    }
193
194    #[test]
195    fn nan_service_rate_maps_to_saturated_in_wait_metrics() {
196        let err = mean_queue_wait_ms(1.0, f64::NAN, 2).expect_err("nan service rate should fail");
197        assert_eq!(err.code(), "SATURATED");
198
199        let err = queue_wait_percentile_ms(1.0, f64::NAN, 2, 0.99).expect_err("nan service rate should fail");
200        assert_eq!(err.code(), "SATURATED");
201    }
202}