Skip to main content

pedalkernel/
tolerance.rs

1//! Component tolerance randomization.
2//!
3//! Real resistors and capacitors are ±5–20% from their nominal values.
4//! Two identical pedals sound slightly different because of this.
5//!
6//! This module provides a seeded pseudo-random tolerance system:
7//! - Each "unit" (physical pedal instance) gets a seed
8//! - Component values are deterministically varied from nominal
9//! - A saved preset always sounds like the same physical pedal
10//! - Two instances of the same model diverge slightly
11//!
12//! The randomization uses a simple but deterministic hash-based approach
13//! so that the same seed + component index always produces the same offset.
14
15/// Tolerance grade for electronic components.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum ToleranceGrade {
18    /// ±1% (precision resistors, film caps). Tight, predictable.
19    Precision,
20    /// ±5% (standard metal film resistors, quality ceramic caps).
21    Standard,
22    /// ±10% (carbon film resistors, electrolytic caps). Vintage character.
23    Loose,
24    /// ±20% (carbon composition resistors, cheap ceramics). Maximum variation.
25    Wide,
26    /// No tolerance applied. Ideal components.
27    Ideal,
28}
29
30impl ToleranceGrade {
31    /// Get the tolerance as a fractional multiplier (e.g., 0.05 for ±5%).
32    pub fn fraction(self) -> f64 {
33        match self {
34            ToleranceGrade::Precision => 0.01,
35            ToleranceGrade::Standard => 0.05,
36            ToleranceGrade::Loose => 0.10,
37            ToleranceGrade::Wide => 0.20,
38            ToleranceGrade::Ideal => 0.0,
39        }
40    }
41}
42
43/// Component tolerance engine.
44///
45/// Given a seed (representing a specific physical pedal unit), generates
46/// deterministic variations for each component. The same seed always
47/// produces the same set of component variations.
48///
49/// Usage:
50/// ```ignore
51/// let tol = ToleranceEngine::new(42, ToleranceGrade::Loose);
52/// let actual_r = tol.apply(nominal_r, 0);  // component index 0
53/// let actual_c = tol.apply(nominal_c, 1);  // component index 1
54/// ```
55#[derive(Debug, Clone)]
56pub struct ToleranceEngine {
57    /// Seed for this specific pedal unit instance.
58    seed: u64,
59    /// Tolerance grade (how much variation to apply).
60    resistor_grade: ToleranceGrade,
61    /// Tolerance grade for capacitors (often looser than resistors).
62    capacitor_grade: ToleranceGrade,
63}
64
65impl ToleranceEngine {
66    /// Create a new tolerance engine with uniform grade for all components.
67    pub fn new(seed: u64, grade: ToleranceGrade) -> Self {
68        Self {
69            seed,
70            resistor_grade: grade,
71            capacitor_grade: grade,
72        }
73    }
74
75    /// Create with separate grades for resistors and capacitors.
76    ///
77    /// Capacitors typically have looser tolerances than resistors in
78    /// vintage pedals. A common combination: ±5% resistors, ±10% caps.
79    pub fn with_grades(
80        seed: u64,
81        resistor_grade: ToleranceGrade,
82        capacitor_grade: ToleranceGrade,
83    ) -> Self {
84        Self {
85            seed,
86            resistor_grade,
87            capacitor_grade,
88        }
89    }
90
91    /// Create an engine that applies no tolerance (ideal components).
92    pub fn ideal() -> Self {
93        Self::new(0, ToleranceGrade::Ideal)
94    }
95
96    /// Apply tolerance to a resistor value.
97    ///
98    /// Returns the actual value after applying a deterministic random
99    /// offset based on the seed and component index.
100    pub fn apply_resistor(&self, nominal: f64, component_index: usize) -> f64 {
101        self.apply_internal(nominal, component_index, self.resistor_grade)
102    }
103
104    /// Apply tolerance to a capacitor value.
105    pub fn apply_capacitor(&self, nominal: f64, component_index: usize) -> f64 {
106        self.apply_internal(nominal, component_index, self.capacitor_grade)
107    }
108
109    /// Core tolerance application.
110    fn apply_internal(&self, nominal: f64, component_index: usize, grade: ToleranceGrade) -> f64 {
111        let fraction = grade.fraction();
112        if fraction == 0.0 {
113            return nominal;
114        }
115
116        // Generate a deterministic pseudo-random value in [-1, 1]
117        // using a hash of (seed, component_index).
118        let random = self.deterministic_random(component_index);
119
120        // Apply tolerance: nominal * (1 + fraction * random)
121        // This gives a value in [nominal * (1-fraction), nominal * (1+fraction)]
122        nominal * (1.0 + fraction * random)
123    }
124
125    /// Deterministic pseudo-random number in [-1, 1] from seed + index.
126    ///
127    /// Uses a simple but effective hash (SplitMix64-derived) to ensure
128    /// good distribution across different seeds and indices.
129    fn deterministic_random(&self, component_index: usize) -> f64 {
130        // Combine seed and index
131        let mut z = self
132            .seed
133            .wrapping_add(component_index as u64)
134            .wrapping_mul(0x9E3779B97F4A7C15);
135        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
136        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
137        z ^= z >> 31;
138
139        // Convert to [-1, 1] range
140        (z as f64 / u64::MAX as f64) * 2.0 - 1.0
141    }
142
143    /// Get the seed.
144    pub fn seed(&self) -> u64 {
145        self.seed
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Tests
151// ---------------------------------------------------------------------------
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn ideal_tolerance_unchanged() {
159        let tol = ToleranceEngine::ideal();
160        let nominal = 4700.0;
161        let actual = tol.apply_resistor(nominal, 0);
162        assert!(
163            (actual - nominal).abs() < 1e-10,
164            "Ideal should not change value: {actual}"
165        );
166    }
167
168    #[test]
169    fn tolerance_deterministic() {
170        let tol1 = ToleranceEngine::new(42, ToleranceGrade::Loose);
171        let tol2 = ToleranceEngine::new(42, ToleranceGrade::Loose);
172
173        for i in 0..20 {
174            let v1 = tol1.apply_resistor(1000.0, i);
175            let v2 = tol2.apply_resistor(1000.0, i);
176            assert!(
177                (v1 - v2).abs() < 1e-10,
178                "Same seed should give same result: i={i}, v1={v1}, v2={v2}"
179            );
180        }
181    }
182
183    #[test]
184    fn tolerance_varies_with_seed() {
185        let tol1 = ToleranceEngine::new(1, ToleranceGrade::Loose);
186        let tol2 = ToleranceEngine::new(2, ToleranceGrade::Loose);
187
188        let v1 = tol1.apply_resistor(1000.0, 0);
189        let v2 = tol2.apply_resistor(1000.0, 0);
190
191        assert!(
192            (v1 - v2).abs() > 0.01,
193            "Different seeds should give different values: v1={v1}, v2={v2}"
194        );
195    }
196
197    #[test]
198    fn tolerance_within_bounds() {
199        let tol = ToleranceEngine::new(123, ToleranceGrade::Loose);
200        let nominal = 10_000.0;
201        let fraction = ToleranceGrade::Loose.fraction(); // 0.10
202
203        for i in 0..1000 {
204            let actual = tol.apply_resistor(nominal, i);
205            let lo = nominal * (1.0 - fraction);
206            let hi = nominal * (1.0 + fraction);
207            assert!(
208                actual >= lo && actual <= hi,
209                "Component {i} out of bounds: {actual}, expected [{lo}, {hi}]"
210            );
211        }
212    }
213
214    #[test]
215    fn tolerance_varies_across_components() {
216        let tol = ToleranceEngine::new(42, ToleranceGrade::Standard);
217        let nominal = 1000.0;
218
219        let values: Vec<f64> = (0..100).map(|i| tol.apply_resistor(nominal, i)).collect();
220
221        // Not all values should be the same
222        let unique: std::collections::HashSet<u64> = values.iter().map(|&v| v.to_bits()).collect();
223        assert!(
224            unique.len() > 50,
225            "Should have good variety across components: {} unique values",
226            unique.len()
227        );
228    }
229
230    #[test]
231    fn tolerance_different_grades() {
232        let precision = ToleranceEngine::new(42, ToleranceGrade::Precision);
233        let wide = ToleranceEngine::new(42, ToleranceGrade::Wide);
234        let nominal = 1000.0;
235
236        // Collect deviations
237        let precision_dev: f64 = (0..100)
238            .map(|i| (precision.apply_resistor(nominal, i) - nominal).abs())
239            .sum::<f64>()
240            / 100.0;
241        let wide_dev: f64 = (0..100)
242            .map(|i| (wide.apply_resistor(nominal, i) - nominal).abs())
243            .sum::<f64>()
244            / 100.0;
245
246        assert!(
247            wide_dev > precision_dev * 3.0,
248            "Wide should deviate much more than precision: wide={wide_dev:.2}, precision={precision_dev:.2}"
249        );
250    }
251
252    #[test]
253    fn separate_resistor_capacitor_grades() {
254        let tol = ToleranceEngine::with_grades(
255            42,
256            ToleranceGrade::Standard, // ±5% for resistors
257            ToleranceGrade::Wide,     // ±20% for caps
258        );
259        let nominal = 1000.0;
260
261        // Capacitor deviation should be larger
262        let r_dev: f64 = (0..100)
263            .map(|i| (tol.apply_resistor(nominal, i) - nominal).abs())
264            .sum::<f64>()
265            / 100.0;
266        let c_dev: f64 = (0..100)
267            .map(|i| (tol.apply_capacitor(nominal, i) - nominal).abs())
268            .sum::<f64>()
269            / 100.0;
270
271        assert!(
272            c_dev > r_dev * 2.0,
273            "Capacitor deviation should be larger: c={c_dev:.2}, r={r_dev:.2}"
274        );
275    }
276
277    #[test]
278    fn tolerance_distribution_centered() {
279        // The distribution should be roughly centered around the nominal value
280        let tol = ToleranceEngine::new(777, ToleranceGrade::Loose);
281        let nominal = 1000.0;
282
283        let mean: f64 = (0..10000)
284            .map(|i| tol.apply_resistor(nominal, i))
285            .sum::<f64>()
286            / 10000.0;
287
288        // Mean should be close to nominal (within 2% for uniform distribution)
289        assert!(
290            (mean - nominal).abs() < nominal * 0.02,
291            "Mean should be near nominal: mean={mean:.2}, nominal={nominal}"
292        );
293    }
294}