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(
99    lambda: f64,
100    mu: f64,
101    c: u32,
102    quantile: f64,
103) -> Result<f64, PoolsimError> {
104    if lambda <= 0.0 {
105        return Ok(0.0);
106    }
107
108    let q = quantile.clamp(0.0, 1.0);
109    if q == 0.0 {
110        return Ok(0.0);
111    }
112
113    let p_wait = erlang_c(lambda, mu, c)?;
114    if q <= 1.0 - p_wait {
115        return Ok(0.0);
116    }
117
118    let rate = c as f64 * mu - lambda;
119    if !rate.is_finite() || rate <= 0.0 {
120        return Err(PoolsimError::Saturated {
121            rho: utilisation(lambda, mu, c),
122        });
123    }
124
125    let tail = ((1.0 - q) / p_wait).max(f64::MIN_POSITIVE);
126    Ok((-tail.ln() / rate) * 1_000.0)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn erlang_c_known_case() {
135        let c = 10;
136        let mu = 1.0;
137        let lambda = 8.0;
138        let p_wait = erlang_c(lambda, mu, c).expect("valid erlang c");
139        assert!((p_wait - 0.40918).abs() < 0.005);
140    }
141
142    #[test]
143    fn erlang_c_reference_matrix() {
144        let mu = 1.0;
145        let cases = [
146            (2, 0.5, 0.33333333),
147            (2, 0.8, 0.71111111),
148            (2, 0.9, 0.85263158),
149            (3, 0.7, 0.49234450),
150            (3, 0.9, 0.81706102),
151            (4, 0.5, 0.17391304),
152            (4, 0.8, 0.59643247),
153            (4, 0.95, 0.89141900),
154            (5, 0.7, 0.37783823),
155            (5, 0.9, 0.76249322),
156            (6, 0.8, 0.51777200),
157            (6, 0.95, 0.86558880),
158            (8, 0.7, 0.27060293),
159            (8, 0.9, 0.70153299),
160            (10, 0.8, 0.40918015),
161            (10, 0.95, 0.82558558),
162            (12, 0.7, 0.18388863),
163            (12, 0.9, 0.64004291),
164            (16, 0.8, 0.30488391),
165            (20, 0.9, 0.55076900),
166        ];
167
168        for (c, rho, expected) in cases {
169            let lambda = rho * c as f64 * mu;
170            let actual = erlang_c(lambda, mu, c).expect("reference case should be valid");
171            assert!(
172                (actual - expected).abs() < 1e-6,
173                "c={c}, rho={rho}, expected={expected}, actual={actual}"
174            );
175        }
176    }
177
178    #[test]
179    fn mean_queue_wait_increases_as_utilisation_rises() {
180        let c = 8;
181        let mu = 1.0;
182        let low =
183            mean_queue_wait_ms(0.5 * c as f64 * mu, mu, c).expect("low utilisation should work");
184        let high =
185            mean_queue_wait_ms(0.9 * c as f64 * mu, mu, c).expect("high utilisation should work");
186        assert!(high > low);
187    }
188
189    #[test]
190    fn queue_percentile_is_zero_when_quantile_in_non_waiting_mass() {
191        let c = 4;
192        let mu = 1.0;
193        let lambda = 0.5 * c as f64 * mu;
194        let p_wait = erlang_c(lambda, mu, c).expect("valid erlang c");
195        let threshold = 1.0 - p_wait;
196        let q = threshold * 0.99;
197        let value = queue_wait_percentile_ms(lambda, mu, c, q).expect("valid percentile");
198        assert_eq!(value, 0.0);
199    }
200
201    #[test]
202    fn nan_service_rate_maps_to_saturated_in_wait_metrics() {
203        let err = mean_queue_wait_ms(1.0, f64::NAN, 2).expect_err("nan service rate should fail");
204        assert_eq!(err.code(), "SATURATED");
205
206        let err = queue_wait_percentile_ms(1.0, f64::NAN, 2, 0.99)
207            .expect_err("nan service rate should fail");
208        assert_eq!(err.code(), "SATURATED");
209    }
210}