Skip to main content

ph_curves/
affine.rs

1//! Standalone invertible `i32` affine transform.
2//!
3//! [`AffineTransform`] applies `y' = (y * gain + offset) / scale` to an
4//! already-converted signed measurement. It is not a
5//! [`crate::TransferFunction`] and does not own or update
6//! [`crate::TransferMetadata`]: the coefficients are caller runtime state,
7//! not table-fit facts.
8//!
9//! [`crate::AffineCalibration`] contains this type and applies it after an
10//! inner transfer. Use the scalar primitive directly when the measurement
11//! already exists as `i32`.
12
13use crate::round::div_nearest_ties_away;
14
15/// Invertible `i32` affine map `y' = (y * gain + offset) / scale`.
16///
17/// Arithmetic uses `i64` intermediates and nearest, ties-away-from-zero
18/// rounding. Identity (modulo rounding when `|scale| ≠ 1`) is
19/// `gain = scale` and `offset = 0`. A negative `gain` or `scale` is allowed
20/// and flips sense.
21///
22/// This type never reads NVM, wraps a transfer, or writes
23/// [`TransferMetadata`]. [`AffineCalibration`] contains one of these and
24/// delegates its gain/offset/scale arithmetic here.
25///
26/// # Inverse
27///
28/// [`unapply`](Self::unapply) solves `y = (y' * scale - offset) / gain` with
29/// the same rounding. Because a zero gain collapses every input onto
30/// `offset / scale`, [`new`](Self::new) rejects `gain == 0` rather than
31/// deferring the failure to `unapply`.
32///
33/// # Numerical scope
34///
35/// For any `i32` `y`, `gain`, and `offset`, the product/sum
36/// `y * gain + offset` always fits in `i64`; the same holds for
37/// `y' * scale - offset` on the inverse path. Both directions still report
38/// [`AffineOverflow::Overflow`] when the rounded result does not fit `i32`.
39///
40/// Both directions round, so `unapply(apply(y))` is bounded rather than
41/// exact. A transform that compresses the scale cannot restore what the
42/// forward quantization discarded. At `i32` extremes, inverse rounding of a
43/// forward result can land just outside `i32`, in which case `unapply`
44/// reports overflow.
45///
46/// [`TransferFunction`]: crate::TransferFunction
47/// [`TransferMetadata`]: crate::TransferMetadata
48/// [`AffineCalibration`]: crate::AffineCalibration
49#[derive(Copy, Clone, Debug, Eq, PartialEq)]
50pub struct AffineTransform {
51    gain: i32,
52    offset: i32,
53    scale: i32,
54}
55
56/// Error returned when affine transform coefficients are invalid.
57#[derive(Copy, Clone, Debug, Eq, PartialEq)]
58pub enum AffineTransformError {
59    /// The scale divisor is zero.
60    ZeroScale,
61    /// The gain is zero, which collapses every input onto one output.
62    ZeroGain,
63}
64
65/// Error returned when affine arithmetic cannot be represented in `i32`.
66///
67/// Distinct from [`crate::TransferError`] and [`crate::InverseTransferError`]:
68/// those carry domain and range variants that a scalar caller has no use for.
69#[derive(Copy, Clone, Debug, Eq, PartialEq)]
70pub enum AffineOverflow {
71    /// The rounded result does not fit in `i32`.
72    Overflow,
73}
74
75impl AffineTransform {
76    /// Construct an invertible affine transform.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`AffineTransformError::ZeroScale`] if `scale == 0`, or
81    /// [`AffineTransformError::ZeroGain`] if `gain == 0`. A zero gain maps
82    /// every input onto the single value `offset / scale` and has no inverse.
83    pub const fn new(gain: i32, offset: i32, scale: i32) -> Result<Self, AffineTransformError> {
84        if scale == 0 {
85            return Err(AffineTransformError::ZeroScale);
86        }
87        if gain == 0 {
88            return Err(AffineTransformError::ZeroGain);
89        }
90        Ok(Self {
91            gain,
92            offset,
93            scale,
94        })
95    }
96
97    /// Return the gain coefficient.
98    pub const fn gain(&self) -> i32 {
99        self.gain
100    }
101
102    /// Return the offset term.
103    pub const fn offset(&self) -> i32 {
104        self.offset
105    }
106
107    /// Return the nonzero scale divisor.
108    pub const fn scale(&self) -> i32 {
109        self.scale
110    }
111
112    /// Apply `y' = (y * gain + offset) / scale`.
113    ///
114    /// Rounding is nearest, ties away from zero.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`AffineOverflow::Overflow`] when the rounded result does not
119    /// fit in `i32`.
120    pub fn apply(&self, value: i32) -> Result<i32, AffineOverflow> {
121        // `scale != 0` is a constructor invariant.
122        debug_assert!(self.scale != 0);
123
124        let product = i64::from(value)
125            .checked_mul(i64::from(self.gain))
126            .ok_or(AffineOverflow::Overflow)?;
127        let numerator = product
128            .checked_add(i64::from(self.offset))
129            .ok_or(AffineOverflow::Overflow)?;
130        let scaled = div_nearest_ties_away(numerator, i64::from(self.scale));
131        i32::try_from(scaled).map_err(|_| AffineOverflow::Overflow)
132    }
133
134    /// Undo `y' = (y * gain + offset) / scale`, recovering `y`.
135    ///
136    /// Solves `y = (y' * scale - offset) / gain` with the same nearest,
137    /// ties-away rounding. Offset is subtracted in `i64` rather than negated
138    /// as `i32`, so `offset == i32::MIN` is representable.
139    ///
140    /// Both directions round, so `unapply(apply(y))` is bounded rather than
141    /// exact: a transform that compresses the scale cannot restore what the
142    /// forward quantization discarded.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`AffineOverflow::Overflow`] when the rounded result does not
147    /// fit in `i32`.
148    pub fn unapply(&self, value: i32) -> Result<i32, AffineOverflow> {
149        debug_assert!(self.gain != 0 && self.scale != 0);
150
151        // `|value * scale|` is at most `2^62`, so neither step can overflow
152        // `i64` for any `i32` operands.
153        let product = i64::from(value)
154            .checked_mul(i64::from(self.scale))
155            .ok_or(AffineOverflow::Overflow)?;
156        let numerator = product
157            .checked_sub(i64::from(self.offset))
158            .ok_or(AffineOverflow::Overflow)?;
159        let uncalibrated = div_nearest_ties_away(numerator, i64::from(self.gain));
160        i32::try_from(uncalibrated).map_err(|_| AffineOverflow::Overflow)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn transform(gain: i32, offset: i32, scale: i32) -> AffineTransform {
169        AffineTransform::new(gain, offset, scale).unwrap()
170    }
171
172    /// Reconstruction error bound in input quanta for `unapply(apply(y))`.
173    ///
174    /// Forward rounding is at most half a scale quantum and inverse rounding
175    /// is at most half a gain quantum. Expressed in input units that is
176    /// bounded by `(|scale| + |gain| - 1) / |gain|`.
177    fn reconstruction_bound(gain: i32, scale: i32) -> i64 {
178        let gain_abs = i64::from(gain.unsigned_abs());
179        let scale_abs = i64::from(scale.unsigned_abs());
180        (scale_abs + gain_abs - 1) / gain_abs
181    }
182
183    #[test]
184    fn constructor_rejects_zero_scale_and_zero_gain() {
185        assert_eq!(
186            AffineTransform::new(1, 0, 0),
187            Err(AffineTransformError::ZeroScale)
188        );
189        assert_eq!(
190            AffineTransform::new(0, 5, 1),
191            Err(AffineTransformError::ZeroGain)
192        );
193        assert_eq!(
194            AffineTransform::new(0, 0, 0),
195            Err(AffineTransformError::ZeroScale)
196        );
197    }
198
199    #[test]
200    fn accessors_echo_the_coefficients() {
201        let t = transform(1_005, -120, 1_000);
202        assert_eq!(t.gain(), 1_005);
203        assert_eq!(t.offset(), -120);
204        assert_eq!(t.scale(), 1_000);
205    }
206
207    #[test]
208    fn identity_is_passthrough() {
209        let t = transform(1, 0, 1);
210        assert_eq!(t.apply(0), Ok(0));
211        assert_eq!(t.apply(42), Ok(42));
212        assert_eq!(t.apply(-7), Ok(-7));
213        assert_eq!(t.apply(i32::MAX), Ok(i32::MAX));
214        assert_eq!(t.apply(i32::MIN), Ok(i32::MIN));
215        assert_eq!(t.unapply(i32::MAX), Ok(i32::MAX));
216        assert_eq!(t.unapply(i32::MIN), Ok(i32::MIN));
217    }
218
219    #[test]
220    fn positive_and_negative_gain_and_scale() {
221        assert_eq!(transform(2, 0, 1).apply(10), Ok(20));
222        assert_eq!(transform(-2, 0, 1).apply(10), Ok(-20));
223        assert_eq!(transform(2, 0, -1).apply(10), Ok(-20));
224        assert_eq!(transform(-2, 0, -1).apply(10), Ok(20));
225
226        // Factory-style +0.5% gain with a -120 numerator offset.
227        let trim = transform(1_005, -120, 1_000);
228        // (-500 * 1005 + -120) / 1000 = -502.62 → -503
229        assert_eq!(trim.apply(-500), Ok(-503));
230        // (0 * 1005 + -120) / 1000 = -0.12 → 0
231        assert_eq!(trim.apply(0), Ok(0));
232        // (2000 * 1005 + -120) / 1000 = 2009.88 → 2010
233        assert_eq!(trim.apply(2_000), Ok(2_010));
234    }
235
236    #[test]
237    fn ties_round_away_from_zero_in_every_sign_combination() {
238        // 1/2 → 1 and -1/2 → -1 (ties away from zero)
239        assert_eq!(transform(1, 0, 2).apply(1), Ok(1));
240        assert_eq!(transform(1, 0, 2).apply(-1), Ok(-1));
241        assert_eq!(transform(1, 0, -2).apply(1), Ok(-1));
242        assert_eq!(transform(1, 0, -2).apply(-1), Ok(1));
243        assert_eq!(transform(1, 0, 2).apply(3), Ok(2));
244        assert_eq!(transform(1, 0, 2).apply(-3), Ok(-2));
245
246        // Inverse ties: (y' * scale) / gain with scale = 1, gain = 2.
247        assert_eq!(transform(2, 0, 1).unapply(1), Ok(1));
248        assert_eq!(transform(2, 0, 1).unapply(-1), Ok(-1));
249        assert_eq!(transform(-2, 0, 1).unapply(1), Ok(-1));
250        assert_eq!(transform(-2, 0, 1).unapply(-1), Ok(1));
251    }
252
253    #[test]
254    fn i32_extremes_are_representable_when_the_result_fits() {
255        let identity = transform(1, 0, 1);
256        assert_eq!(identity.apply(i32::MAX), Ok(i32::MAX));
257        assert_eq!(identity.apply(i32::MIN), Ok(i32::MIN));
258        assert_eq!(identity.unapply(i32::MAX), Ok(i32::MAX));
259        assert_eq!(identity.unapply(i32::MIN), Ok(i32::MIN));
260
261        // Offset at i32::MIN is subtracted in i64, not negated as i32.
262        let shifted = transform(1, i32::MIN, 1);
263        assert_eq!(shifted.apply(0), Ok(i32::MIN));
264        assert_eq!(shifted.unapply(i32::MIN), Ok(0));
265        assert_eq!(shifted.apply(1), Ok(i32::MIN + 1));
266        assert_eq!(shifted.unapply(i32::MIN + 1), Ok(1));
267
268        let high_offset = transform(1, i32::MAX, 1);
269        assert_eq!(high_offset.apply(0), Ok(i32::MAX));
270        assert_eq!(high_offset.unapply(i32::MAX), Ok(0));
271    }
272
273    #[test]
274    fn representable_intermediates_still_overflow_i32() {
275        // i32::MAX * 2 fits in i64 and overflows i32.
276        assert_eq!(
277            transform(2, 0, 1).apply(i32::MAX),
278            Err(AffineOverflow::Overflow)
279        );
280        assert_eq!(
281            transform(i32::MAX, 0, 1).apply(2),
282            Err(AffineOverflow::Overflow)
283        );
284        // Negating i32::MIN overflows i32 even though the i64 product fits.
285        assert_eq!(
286            transform(-1, 0, 1).apply(i32::MIN),
287            Err(AffineOverflow::Overflow)
288        );
289        // Inverse: MAX * MAX / 1 does not fit i32.
290        assert_eq!(
291            transform(1, 0, i32::MAX).unapply(i32::MAX),
292            Err(AffineOverflow::Overflow)
293        );
294    }
295
296    #[test]
297    fn identity_round_trips_are_exact() {
298        let t = transform(1, 0, 1);
299        for y in [i32::MIN, i32::MIN + 1, -1, 0, 1, i32::MAX - 1, i32::MAX] {
300            let applied = t.apply(y).unwrap();
301            assert_eq!(t.unapply(applied), Ok(y));
302            let undone = t.unapply(y).unwrap();
303            assert_eq!(t.apply(undone), Ok(y));
304        }
305    }
306
307    #[test]
308    fn compressing_round_trips_are_bounded_not_exact() {
309        let t = transform(2, 0, 3);
310        assert_eq!(t.apply(1), Ok(1));
311        assert_eq!(t.unapply(1), Ok(2));
312        assert_ne!(t.unapply(t.apply(1).unwrap()), Ok(1));
313
314        let bound = reconstruction_bound(2, 3);
315        for y in -2_000..=2_000 {
316            let applied = t.apply(y).unwrap();
317            let recovered = t.unapply(applied).unwrap();
318            let error = i64::from(recovered) - i64::from(y);
319            assert!(
320                error.abs() <= bound,
321                "y={y}: apply -> {applied} -> unapply -> {recovered} (Δ={error}, bound={bound})"
322            );
323        }
324    }
325
326    #[test]
327    fn interior_apply_results_are_unapplyable() {
328        let cases = [
329            transform(1, 0, 1),
330            transform(1_005, -120, 1_000),
331            transform(2, 0, 3),
332            transform(-2, 0, 3),
333            transform(3, 0, 2),
334            transform(1, i32::MIN, 1),
335            transform(-1, 0, 2),
336        ];
337        let samples = [-10_000, -1, 0, 1, 10_000];
338
339        for t in cases {
340            for y in samples {
341                let Ok(applied) = t.apply(y) else {
342                    continue;
343                };
344                t.unapply(applied).unwrap_or_else(|_| {
345                    panic!(
346                        "apply({y}) -> {applied} was not unapplyable for gain={} offset={} scale={}",
347                        t.gain(),
348                        t.offset(),
349                        t.scale()
350                    )
351                });
352            }
353        }
354    }
355
356    #[test]
357    fn inverse_rounding_can_overflow_at_i32_max() {
358        // apply(MAX) with 2/3 lands on a value whose ties-away inverse is
359        // MAX + 0.5, which does not fit i32. MIN is representable because
360        // the matching negative half-quantum is i32::MIN itself.
361        let t = transform(2, 0, 3);
362        let applied_max = t.apply(i32::MAX).unwrap();
363        assert_eq!(t.unapply(applied_max), Err(AffineOverflow::Overflow));
364        let applied_min = t.apply(i32::MIN).unwrap();
365        assert_eq!(t.unapply(applied_min), Ok(i32::MIN));
366    }
367
368    #[test]
369    fn expanding_round_trips_stay_within_the_bound() {
370        let t = transform(3, 0, 2);
371        let bound = reconstruction_bound(3, 2);
372        for y in -2_000..=2_000 {
373            let Ok(applied) = t.apply(y) else {
374                continue;
375            };
376            let recovered = t.unapply(applied).unwrap();
377            let error = i64::from(recovered) - i64::from(y);
378            assert!(
379                error.abs() <= bound,
380                "y={y}: apply -> {applied} -> unapply -> {recovered} (Δ={error}, bound={bound})"
381            );
382        }
383    }
384}