Skip to main content

ph_curves/
transfer.rs

1//! Integer-only transfer functions for physical measurements.
2//!
3//! Transfer functions map an integer observation, such as an ADC code, to a
4//! signed measurement value in a declared scale. Unlike normalized curves,
5//! they are not coupled to [`crate::UnitValue`] or tickless scheduling.
6//!
7//! Inverse conversion maps a physical setpoint back to an observation using
8//! the same sparse knot tables — no dense physical-domain LUT.
9
10use crate::affine::{AffineOverflow, AffineTransform, AffineTransformError};
11use crate::round::div_nearest_ties_away;
12
13/// A conversion from an observation to a measurement.
14pub trait TransferFunction {
15    /// Input observation type.
16    type Input: Copy;
17    /// Output measurement type.
18    type Output: Copy;
19
20    /// Convert an observation to a measurement.
21    ///
22    /// When a table declares an [`ObservationGuard`], that exact code is
23    /// classified first. Remaining inputs outside the table domain follow the
24    /// table's explicit lower and upper [`BoundaryBehavior`] settings.
25    /// Transfer functions never extrapolate.
26    fn convert(&self, input: Self::Input) -> Result<Self::Output, TransferError<Self::Input>>;
27}
28
29/// A conversion from a physical measurement back to an observation.
30///
31/// Parallel to [`TransferFunction`]; not a supertrait, because the map
32/// direction and error type differ.
33pub trait InverseTransferFunction {
34    /// Physical measurement type (input to inverse).
35    type Physical: Copy;
36    /// Observation type (output of inverse).
37    type Observation: Copy;
38
39    /// Invert a physical measurement to an observation.
40    ///
41    /// Values outside the table's physical range follow the same
42    /// [`BoundaryBehavior`] settings as the forward direction, mapped through
43    /// the table's [`MonotonicDirection`] so both directions agree about the
44    /// same out-of-range condition. On a decreasing table the codes above
45    /// `domain_max` are the ones producing physical values below `range_min`,
46    /// so `above` governs the low-physical side there. Transfer inverses never
47    /// extrapolate.
48    fn invert(
49        &self,
50        physical: Self::Physical,
51    ) -> Result<Self::Observation, InverseTransferError<Self::Physical>>;
52}
53
54/// Monotonic direction of a transfer function's output.
55#[derive(Copy, Clone, Debug, Eq, PartialEq)]
56pub enum MonotonicDirection {
57    /// Output values do not decrease as input increases.
58    Increasing,
59    /// Output values do not increase as input increases.
60    Decreasing,
61}
62
63/// Behavior for observations outside one side of a transfer domain.
64///
65/// Both settings are declared against the **observation domain**. For
66/// [`InverseTransferFunction`] they are mapped onto the physical range through
67/// the table's [`MonotonicDirection`], so `below` governs whichever end of the
68/// physical range corresponds to inputs under `domain_min` — the low end on an
69/// increasing table, the high end on a decreasing one. See
70/// [`PiecewiseLinearTransfer::range_behaviors`].
71#[derive(Copy, Clone, Debug, Eq, PartialEq)]
72pub enum BoundaryBehavior {
73    /// Return a domain/range error.
74    Error,
75    /// Return the nearest endpoint value.
76    Clamp,
77}
78
79/// Policy applied when one explicitly declared observation code is seen.
80///
81/// Distinct from [`BoundaryBehavior`]: ordinary codes outside the fitted
82/// domain follow `below` / `above`, while this policy applies only to the
83/// guarded code and is not mapped through [`MonotonicDirection`].
84#[derive(Copy, Clone, Debug, Eq, PartialEq)]
85pub enum ObservationGuardBehavior {
86    /// Return [`TransferError::RejectedObservation`].
87    Error,
88    /// Return the output at `domain_max`, regardless of the `above` policy.
89    Clamp,
90}
91
92/// One explicitly declared observation code and the policy applied to it.
93///
94/// The code is consumer or device policy, not inferred from its integer
95/// value. It must be strictly above the table's fitted `domain_max`.
96#[derive(Copy, Clone, Debug, Eq, PartialEq)]
97pub struct ObservationGuard {
98    /// Observation code classified by this guard.
99    pub code: u16,
100    /// Policy applied when `code` is observed.
101    pub behavior: ObservationGuardBehavior,
102}
103
104/// Compact facts for a transfer's optional observation-code guard.
105///
106/// Adjacent to [`TransferMetadata`] rather than a required field on it, so
107/// generated struct literals for table metadata stay additive. Classification
108/// of a code as saturation is declared consumer/device policy, not inferred
109/// from the integer value.
110#[derive(Copy, Clone, Debug, Eq, PartialEq)]
111pub struct ObservationGuardMetadata {
112    /// Observation code classified by this guard.
113    pub code: u16,
114    /// Policy applied when `code` is observed.
115    pub behavior: ObservationGuardBehavior,
116}
117
118/// How to resolve a physical value that lands on a flat (non-unique) output run.
119#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
120pub enum FlatResolution {
121    /// Return the smallest input of the flat run.
122    #[default]
123    PreferLowInput,
124    /// Return the largest input of the flat run.
125    PreferHighInput,
126    /// Return `(low + high) / 2`, truncating toward the low input.
127    Midpoint,
128    /// Return [`InverseTransferError::AmbiguousFlat`].
129    Error,
130}
131
132/// Error returned when an observation is outside a transfer domain, a
133/// declared observation guard rejects it, or affine calibration arithmetic
134/// cannot be represented.
135#[derive(Copy, Clone, Debug, Eq, PartialEq)]
136pub enum TransferError<I> {
137    /// The observation is below the minimum supported input.
138    BelowDomain {
139        /// Observation supplied by the caller.
140        input: I,
141        /// Smallest supported input.
142        minimum: I,
143    },
144    /// The observation is above the maximum supported input.
145    AboveDomain {
146        /// Observation supplied by the caller.
147        input: I,
148        /// Largest supported input.
149        maximum: I,
150    },
151    /// The observation matches an explicit [`ObservationGuard`] whose policy
152    /// is [`ObservationGuardBehavior::Error`].
153    ///
154    /// Distinct from [`Self::AboveDomain`]: the code was declared as a
155    /// rejected observation, not as an ordinary domain violation.
156    RejectedObservation {
157        /// Observation supplied by the caller.
158        input: I,
159    },
160    /// Affine calibration overflowed `i64` intermediates or the final `i32`
161    /// result. Domain policy from the inner transfer is unchanged.
162    Overflow,
163}
164
165/// Error returned when a physical value cannot be inverted uniquely.
166#[derive(Copy, Clone, Debug, Eq, PartialEq)]
167pub enum InverseTransferError<P> {
168    /// The physical value is below the minimum supported output.
169    BelowRange {
170        /// Physical value supplied by the caller.
171        physical: P,
172        /// Smallest supported physical output.
173        minimum: P,
174    },
175    /// The physical value is above the maximum supported output.
176    AboveRange {
177        /// Physical value supplied by the caller.
178        physical: P,
179        /// Largest supported physical output.
180        maximum: P,
181    },
182    /// The physical value lies on a flat run and [`FlatResolution::Error`] is set.
183    AmbiguousFlat {
184        /// Physical value supplied by the caller.
185        physical: P,
186        /// Smallest observation on the flat run.
187        low: u16,
188        /// Largest observation on the flat run.
189        high: u16,
190    },
191    /// Undoing affine calibration produced a value outside `i32`, or a range
192    /// bound could not be re-expressed in calibrated units.
193    Overflow,
194}
195
196/// Error returned for invalid standalone segment interpolation arguments.
197#[derive(Copy, Clone, Debug, Eq, PartialEq)]
198pub enum InterpolationError {
199    /// Segment inputs are not strictly increasing.
200    InvalidSpan,
201    /// The interpolation input is outside the closed segment.
202    OutsideSegment {
203        /// Observation supplied by the caller.
204        input: u16,
205        /// Segment's lower input.
206        minimum: u16,
207        /// Segment's upper input.
208        maximum: u16,
209    },
210    /// Segment outputs are equal, so the segment cannot be inverted uniquely.
211    FlatSegment,
212    /// The physical value is outside the closed segment's output span.
213    OutsidePhysicalSpan {
214        /// Physical value supplied by the caller.
215        physical: i32,
216        /// Segment's lower output.
217        minimum: i32,
218        /// Segment's upper output.
219        maximum: i32,
220    },
221}
222
223/// Compact facts recorded by the host generator for a transfer table.
224///
225/// The error fields describe numerical table and output-quantization error
226/// against the configured ideal source. They do not include sensor, component,
227/// ADC, model, self-heating, or calibration uncertainty.
228#[derive(Copy, Clone, Debug, Eq, PartialEq)]
229pub struct TransferMetadata {
230    /// Human-readable input unit, such as `"adc_code"` or `"millivolt"`.
231    pub input_unit: &'static str,
232    /// Human-readable physical output unit, such as `"degree_celsius"`.
233    pub output_unit: &'static str,
234    /// Integer output quanta per physical output unit.
235    pub output_scale: u32,
236    /// Inclusive minimum input.
237    pub domain_min: u16,
238    /// Inclusive maximum input.
239    pub domain_max: u16,
240    /// Inclusive minimum physical output (endpoint of the knot range).
241    pub range_min: i32,
242    /// Inclusive maximum physical output.
243    pub range_max: i32,
244    /// Output monotonic direction.
245    pub direction: MonotonicDirection,
246    /// Number of piecewise-linear knots.
247    pub knot_count: usize,
248    /// True when every adjacent knot pair has unequal outputs.
249    pub strictly_monotonic: bool,
250    /// Count of adjacent knot pairs with equal outputs.
251    pub flat_segment_count: usize,
252    /// Requested maximum numerical error in output quanta.
253    pub requested_max_error: u32,
254    /// Exhaustively measured, conservatively rounded-up maximum error.
255    pub achieved_max_error: u32,
256    /// Input where the achieved maximum error first occurs.
257    pub worst_case_input: u16,
258    /// Exhaustively host-measured worst
259    /// `|invert(convert(x)) as i32 − x as i32|` over the input domain, in
260    /// codes, under the table's default [`FlatResolution`].
261    ///
262    /// This is a measured round-trip bound, not a promise of identity: a
263    /// nonzero value means some observations do not survive a
264    /// convert-then-invert cycle exactly. Flat runs are resolved with
265    /// [`FlatResolution::PreferLowInput`]; overriding the policy with
266    /// [`PiecewiseLinearTransfer::with_flat_resolution`] can exceed this
267    /// bound on tables where `flat_segment_count` is nonzero.
268    pub achieved_max_inverse_code_error: u16,
269}
270
271/// A sparse, nonuniform, piecewise-linear `u16` to `i32` transfer function.
272///
273/// Inputs are searched in `O(log N)` time. The two arrays use six bytes of
274/// table payload per knot and require no allocation. Inverse conversion
275/// binary-searches the same output knots — no dense physical→input LUT.
276///
277/// An optional [`ObservationGuard`] is stored on the table itself. A transfer
278/// constructed without one keeps the previous convert/invert behavior; its
279/// layout may grow by that optional field. Exact `size_of` is
280/// target/ABI-dependent.
281#[derive(Copy, Clone, Debug)]
282pub struct PiecewiseLinearTransfer<const N: usize> {
283    inputs: &'static [u16; N],
284    outputs: &'static [i32; N],
285    direction: MonotonicDirection,
286    below: BoundaryBehavior,
287    above: BoundaryBehavior,
288    flat_resolution: FlatResolution,
289    observation_guard: Option<ObservationGuard>,
290}
291
292impl<const N: usize> PiecewiseLinearTransfer<N> {
293    /// Construct a transfer whose below/above behaviors both default to error.
294    ///
295    /// Flat runs default to [`FlatResolution::PreferLowInput`].
296    ///
297    /// # Panics
298    ///
299    /// Panics while defining the table if it has fewer than two knots, inputs
300    /// are not strictly increasing, or outputs violate `direction`. Generated
301    /// tables call this in a constant context, making invalid tables a compile
302    /// error. Conversion of caller-supplied observations does not panic.
303    pub const fn new(
304        inputs: &'static [u16; N],
305        outputs: &'static [i32; N],
306        direction: MonotonicDirection,
307    ) -> Self {
308        assert!(N >= 2);
309
310        let mut index = 1;
311        while index < N {
312            assert!(inputs[index] > inputs[index - 1]);
313            match direction {
314                MonotonicDirection::Increasing => {
315                    assert!(outputs[index] >= outputs[index - 1]);
316                }
317                MonotonicDirection::Decreasing => {
318                    assert!(outputs[index] <= outputs[index - 1]);
319                }
320            }
321            index += 1;
322        }
323
324        Self {
325            inputs,
326            outputs,
327            direction,
328            below: BoundaryBehavior::Error,
329            above: BoundaryBehavior::Error,
330            flat_resolution: FlatResolution::PreferLowInput,
331            observation_guard: None,
332        }
333    }
334
335    /// Set independent below-domain and above-domain behavior.
336    ///
337    /// Both are declared against the observation domain. Inverse conversion
338    /// maps them onto the physical range through the table's direction; see
339    /// [`range_behaviors`](Self::range_behaviors).
340    pub const fn with_boundaries(
341        mut self,
342        below: BoundaryBehavior,
343        above: BoundaryBehavior,
344    ) -> Self {
345        self.below = below;
346        self.above = above;
347        self
348    }
349
350    /// Set how flat (equal-output) runs are resolved by [`invert`](InverseTransferFunction::invert).
351    pub const fn with_flat_resolution(mut self, policy: FlatResolution) -> Self {
352        self.flat_resolution = policy;
353        self
354    }
355
356    /// Set an explicit observation-code guard independent of `below` / `above`.
357    ///
358    /// The guarded code is classified before ordinary domain policy and is
359    /// not mapped through inverse range behavior. Classification of a code as
360    /// saturation is declared consumer/device policy, not inferred from the
361    /// integer value.
362    ///
363    /// # Panics
364    ///
365    /// Panics while defining the table if `code` is not strictly above the
366    /// fitted `domain_max`. Generated tables call this in a constant context,
367    /// making an invalid guard a compile error.
368    pub const fn with_observation_guard(
369        mut self,
370        code: u16,
371        behavior: ObservationGuardBehavior,
372    ) -> Self {
373        assert!(
374            code > self.inputs[N - 1],
375            "observation guard code must be strictly above domain_max"
376        );
377        self.observation_guard = Some(ObservationGuard { code, behavior });
378        self
379    }
380
381    /// Return the explicit observation-code guard, if one is set.
382    pub const fn observation_guard(&self) -> Option<ObservationGuard> {
383        self.observation_guard
384    }
385
386    /// Return the input knot array.
387    pub const fn inputs(&self) -> &'static [u16; N] {
388        self.inputs
389    }
390
391    /// Return the output knot array.
392    pub const fn outputs(&self) -> &'static [i32; N] {
393        self.outputs
394    }
395
396    /// Return the output monotonic direction.
397    pub const fn direction(&self) -> MonotonicDirection {
398        self.direction
399    }
400
401    /// Return the below-observation-domain behavior.
402    ///
403    /// Declared against the observation domain, not the physical range. For
404    /// the physical-side policies used by inverse conversion, see
405    /// [`range_behaviors`](Self::range_behaviors).
406    pub const fn below_behavior(&self) -> BoundaryBehavior {
407        self.below
408    }
409
410    /// Return the above-observation-domain behavior.
411    ///
412    /// Declared against the observation domain, not the physical range. For
413    /// the physical-side policies used by inverse conversion, see
414    /// [`range_behaviors`](Self::range_behaviors).
415    pub const fn above_behavior(&self) -> BoundaryBehavior {
416        self.above
417    }
418
419    /// Return the flat-run resolution policy.
420    pub const fn flat_resolution(&self) -> FlatResolution {
421        self.flat_resolution
422    }
423
424    /// Return the inclusive input domain.
425    pub const fn domain(&self) -> (u16, u16) {
426        (self.inputs[0], self.inputs[N - 1])
427    }
428
429    /// Return the inclusive physical output range as `(min, max)`.
430    pub const fn physical_range(&self) -> (i32, i32) {
431        let first = self.outputs[0];
432        let last = self.outputs[N - 1];
433        if first <= last {
434            (first, last)
435        } else {
436            (last, first)
437        }
438    }
439
440    /// Invert a physical measurement to an observation.
441    ///
442    /// Convenience alias for [`InverseTransferFunction::invert`].
443    pub fn invert_physical(&self, physical: i32) -> Result<u16, InverseTransferError<i32>> {
444        InverseTransferFunction::invert(self, physical)
445    }
446
447    /// Map the domain policies onto the physical range as
448    /// `(low_physical, high_physical)`.
449    ///
450    /// `below` and `above` are declared against the *observation* domain, so
451    /// on a decreasing table they swap: the codes above `domain_max` are the
452    /// ones that produce physical values below `range_min`. Selecting by
453    /// physical side alone would make a table configured
454    /// `below = Error, above = Clamp` clamp in the forward direction and error
455    /// in the inverse for the very same out-of-range condition.
456    pub const fn range_behaviors(&self) -> (BoundaryBehavior, BoundaryBehavior) {
457        match self.direction {
458            MonotonicDirection::Increasing => (self.below, self.above),
459            MonotonicDirection::Decreasing => (self.above, self.below),
460        }
461    }
462
463    fn observation_at_physical_end(&self, low_physical: bool) -> u16 {
464        match (self.direction, low_physical) {
465            (MonotonicDirection::Increasing, true) | (MonotonicDirection::Decreasing, false) => {
466                self.inputs[0]
467            }
468            (MonotonicDirection::Increasing, false) | (MonotonicDirection::Decreasing, true) => {
469                self.inputs[N - 1]
470            }
471        }
472    }
473
474    fn resolve_flat_run(
475        &self,
476        physical: i32,
477        left: usize,
478        right: usize,
479    ) -> Result<u16, InverseTransferError<i32>> {
480        let low = self.inputs[left];
481        let high = self.inputs[right];
482        if left == right {
483            return Ok(low);
484        }
485        match self.flat_resolution {
486            FlatResolution::PreferLowInput => Ok(low),
487            FlatResolution::PreferHighInput => Ok(high),
488            FlatResolution::Midpoint => Ok(low + (high - low) / 2),
489            FlatResolution::Error => Err(InverseTransferError::AmbiguousFlat {
490                physical,
491                low,
492                high,
493            }),
494        }
495    }
496
497    fn expand_flat_run(&self, index: usize) -> (usize, usize) {
498        let value = self.outputs[index];
499        let mut left = index;
500        while left > 0 && self.outputs[left - 1] == value {
501            left -= 1;
502        }
503        let mut right = index;
504        while right + 1 < N && self.outputs[right + 1] == value {
505            right += 1;
506        }
507        (left, right)
508    }
509
510    /// Largest knot index whose output is on the inclusive low-physical side
511    /// of `physical` for the table's monotonic direction.
512    fn largest_index_at_or_past(&self, physical: i32) -> usize {
513        let mut low = 0usize;
514        let mut high = N - 1;
515        while low < high {
516            let middle = low + (high - low).div_ceil(2);
517            let past = match self.direction {
518                MonotonicDirection::Increasing => self.outputs[middle] <= physical,
519                MonotonicDirection::Decreasing => self.outputs[middle] >= physical,
520            };
521            if past {
522                low = middle;
523            } else {
524                high = middle - 1;
525            }
526        }
527        low
528    }
529}
530
531impl<const N: usize> TransferFunction for PiecewiseLinearTransfer<N> {
532    type Input = u16;
533    type Output = i32;
534
535    fn convert(&self, input: u16) -> Result<i32, TransferError<u16>> {
536        let minimum = self.inputs[0];
537        let maximum = self.inputs[N - 1];
538
539        if let Some(guard) = self.observation_guard
540            && input == guard.code
541        {
542            return match guard.behavior {
543                ObservationGuardBehavior::Error => {
544                    Err(TransferError::RejectedObservation { input })
545                }
546                ObservationGuardBehavior::Clamp => Ok(self.outputs[N - 1]),
547            };
548        }
549
550        if input < minimum {
551            return match self.below {
552                BoundaryBehavior::Error => Err(TransferError::BelowDomain { input, minimum }),
553                BoundaryBehavior::Clamp => Ok(self.outputs[0]),
554            };
555        }
556        if input > maximum {
557            return match self.above {
558                BoundaryBehavior::Error => Err(TransferError::AboveDomain { input, maximum }),
559                BoundaryBehavior::Clamp => Ok(self.outputs[N - 1]),
560            };
561        }
562        if input == minimum {
563            return Ok(self.outputs[0]);
564        }
565        if input == maximum {
566            return Ok(self.outputs[N - 1]);
567        }
568
569        let mut low = 0usize;
570        let mut high = N - 1;
571        while low + 1 < high {
572            let middle = low + (high - low) / 2;
573            match input.cmp(&self.inputs[middle]) {
574                core::cmp::Ordering::Less => high = middle,
575                core::cmp::Ordering::Equal => return Ok(self.outputs[middle]),
576                core::cmp::Ordering::Greater => low = middle,
577            }
578        }
579
580        Ok(interpolate_valid_segment(
581            input,
582            self.inputs[low],
583            self.outputs[low],
584            self.inputs[high],
585            self.outputs[high],
586        ))
587    }
588}
589
590impl<const N: usize> InverseTransferFunction for PiecewiseLinearTransfer<N> {
591    type Physical = i32;
592    type Observation = u16;
593
594    fn invert(&self, physical: i32) -> Result<u16, InverseTransferError<i32>> {
595        let (minimum, maximum) = self.physical_range();
596        let (low_physical, high_physical) = self.range_behaviors();
597
598        if physical < minimum {
599            return match low_physical {
600                BoundaryBehavior::Error => {
601                    Err(InverseTransferError::BelowRange { physical, minimum })
602                }
603                BoundaryBehavior::Clamp => Ok(self.observation_at_physical_end(true)),
604            };
605        }
606        if physical > maximum {
607            return match high_physical {
608                BoundaryBehavior::Error => {
609                    Err(InverseTransferError::AboveRange { physical, maximum })
610                }
611                BoundaryBehavior::Clamp => Ok(self.observation_at_physical_end(false)),
612            };
613        }
614
615        let index = self.largest_index_at_or_past(physical);
616        if self.outputs[index] == physical {
617            let (left, right) = self.expand_flat_run(index);
618            return self.resolve_flat_run(physical, left, right);
619        }
620
621        // `index` is the last knot on the inclusive low-physical side, so the
622        // bracketing sloped segment is `index .. index + 1`.
623        debug_assert!(index + 1 < N);
624        Ok(invert_valid_segment(
625            physical,
626            self.inputs[index],
627            self.outputs[index],
628            self.inputs[index + 1],
629            self.outputs[index + 1],
630        ))
631    }
632}
633
634/// Interpolate one signed integer segment with nearest, ties-away rounding.
635///
636/// The input must lie within the closed segment and `x1` must be greater than
637/// `x0`. All arithmetic uses `i64`; the full `u16`/`i32` ranges are safe.
638pub fn interpolate_segment(
639    input: u16,
640    x0: u16,
641    y0: i32,
642    x1: u16,
643    y1: i32,
644) -> Result<i32, InterpolationError> {
645    if x1 <= x0 {
646        return Err(InterpolationError::InvalidSpan);
647    }
648    if input < x0 || input > x1 {
649        return Err(InterpolationError::OutsideSegment {
650            input,
651            minimum: x0,
652            maximum: x1,
653        });
654    }
655    Ok(interpolate_valid_segment(input, x0, y0, x1, y1))
656}
657
658/// Invert one signed integer segment with nearest, ties-away rounding.
659///
660/// The mirror of [`interpolate_segment`]. `physical` must lie within the
661/// closed output span, `x1` must be greater than `x0`, and the segment must
662/// not be flat. All arithmetic uses `i64`; the full `u16`/`i32` ranges are
663/// safe. Host tools use this so a generated round-trip audit measures the
664/// same arithmetic the runtime performs.
665pub fn invert_segment(
666    physical: i32,
667    x0: u16,
668    y0: i32,
669    x1: u16,
670    y1: i32,
671) -> Result<u16, InterpolationError> {
672    if x1 <= x0 {
673        return Err(InterpolationError::InvalidSpan);
674    }
675    if y0 == y1 {
676        return Err(InterpolationError::FlatSegment);
677    }
678    let (minimum, maximum) = if y0 < y1 { (y0, y1) } else { (y1, y0) };
679    if physical < minimum || physical > maximum {
680        return Err(InterpolationError::OutsidePhysicalSpan {
681            physical,
682            minimum,
683            maximum,
684        });
685    }
686    Ok(invert_valid_segment(physical, x0, y0, x1, y1))
687}
688
689fn interpolate_valid_segment(input: u16, x0: u16, y0: i32, x1: u16, y1: i32) -> i32 {
690    let offset = i64::from(input - x0);
691    let span = i64::from(x1 - x0);
692    let delta = i64::from(y1) - i64::from(y0);
693    let numerator = i64::from(y0) * span + delta * offset;
694    let result = div_nearest_ties_away(numerator, span);
695    debug_assert!((i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&result));
696    result as i32
697}
698
699fn invert_valid_segment(physical: i32, x0: u16, y0: i32, x1: u16, y1: i32) -> u16 {
700    let dy = i64::from(y1) - i64::from(y0);
701    debug_assert!(dy != 0);
702    let dx = i64::from(x1) - i64::from(x0);
703    let numerator = i64::from(x0) * dy + (i64::from(physical) - i64::from(y0)) * dx;
704    let result = div_nearest_ties_away(numerator, dy);
705    result.clamp(i64::from(x0), i64::from(x1)) as u16
706}
707
708/// Runtime/factory gain-and-offset wrapper around an inner transfer.
709///
710/// Applies `y' = (y * gain + offset) / scale` by containing an
711/// [`AffineTransform`] and running it after the inner conversion. The caller
712/// supplies the integer triple (for example from EEPROM or flash); this type
713/// never reads NVM, regenerates knot tables, or updates [`TransferMetadata`].
714///
715/// Identity (modulo rounding when `|scale| ≠ 1`) is `gain = scale` and
716/// `offset = 0`. A negative `gain` or `scale` is allowed and flips sense.
717/// Nesting multiple wrappers is permitted via [`TransferFunction`], but
718/// precision loss and overflow risk stack with each layer.
719///
720/// Use [`AffineTransform`] directly when the measurement already exists as
721/// `i32` and there is no inner transfer to wrap.
722///
723/// # Inverse
724///
725/// When the inner type implements [`InverseTransferFunction`], so does the
726/// wrapper: [`invert`](InverseTransferFunction::invert) undoes the affine with
727/// [`AffineTransform::unapply`] and then inverts the inner transfer.
728/// That is what makes a calibrated setpoint — "which ADC code reads 25 °C
729/// *after* this unit's factory calibration?" — a single call.
730///
731/// Because `gain` must be nonzero for the affine to be invertible,
732/// [`AffineCalibration::new`] rejects `gain == 0` outright rather than
733/// deferring the failure to `invert`.
734///
735/// # Numerical scope
736///
737/// For any `i32` `y`, `gain`, and `offset`, the product/sum
738/// `y * gain + offset` always fits in `i64`; the same holds for
739/// `y' * scale - offset` on the inverse path. Both directions still report
740/// overflow — [`TransferError::Overflow`] forward,
741/// [`InverseTransferError::Overflow`] inverse — when the result does not fit
742/// `i32`.
743///
744/// Both directions round, so a convert-then-invert round trip through a
745/// calibration is bounded, not exact. A calibration that compresses the
746/// physical scale cannot restore what the forward quantization discarded.
747/// When that compression would make `unapply` overshoot an inner endpoint
748/// for a calibrated value that is still in the forward image,
749/// [`invert`](InverseTransferFunction::invert) clamps to the endpoint rather
750/// than returning a spurious range error — so `invert(convert(x))` stays
751/// in-domain for every in-domain observation `x`.
752#[derive(Copy, Clone, Debug)]
753pub struct AffineCalibration<T> {
754    inner: T,
755    transform: AffineTransform,
756}
757
758/// Error returned when affine calibration constants are invalid.
759#[derive(Copy, Clone, Debug, Eq, PartialEq)]
760pub enum AffineCalibrationError {
761    /// The scale divisor is zero.
762    ZeroScale,
763    /// The gain is zero, which collapses every observation onto one output.
764    ZeroGain,
765}
766
767impl<T> AffineCalibration<T> {
768    /// Wrap `inner` with affine calibration constants.
769    ///
770    /// # Errors
771    ///
772    /// Returns [`AffineCalibrationError::ZeroScale`] if `scale == 0`, or
773    /// [`AffineCalibrationError::ZeroGain`] if `gain == 0`. A zero gain maps
774    /// every observation onto the single value `offset / scale`, discarding
775    /// the sensor and leaving the calibration non-invertible.
776    pub fn new(
777        inner: T,
778        gain: i32,
779        offset: i32,
780        scale: i32,
781    ) -> Result<Self, AffineCalibrationError> {
782        let transform = AffineTransform::new(gain, offset, scale).map_err(|error| match error {
783            AffineTransformError::ZeroScale => AffineCalibrationError::ZeroScale,
784            AffineTransformError::ZeroGain => AffineCalibrationError::ZeroGain,
785        })?;
786        Ok(Self { inner, transform })
787    }
788
789    /// Return a reference to the inner transfer.
790    pub const fn inner(&self) -> &T {
791        &self.inner
792    }
793
794    /// Return the contained scalar transform.
795    ///
796    /// The same coefficients can be applied to an already-converted `i32`
797    /// measurement without going through the inner transfer.
798    pub const fn transform(&self) -> AffineTransform {
799        self.transform
800    }
801
802    /// Return the gain coefficient.
803    pub const fn gain(&self) -> i32 {
804        self.transform.gain()
805    }
806
807    /// Return the offset term.
808    pub const fn offset(&self) -> i32 {
809        self.transform.offset()
810    }
811
812    /// Return the nonzero scale divisor.
813    pub const fn scale(&self) -> i32 {
814        self.transform.scale()
815    }
816}
817
818impl<T> TransferFunction for AffineCalibration<T>
819where
820    T: TransferFunction<Output = i32>,
821{
822    type Input = T::Input;
823    type Output = i32;
824
825    fn convert(&self, input: Self::Input) -> Result<i32, TransferError<Self::Input>> {
826        let y = self.inner.convert(input)?;
827        self.transform
828            .apply(y)
829            .map_err(|AffineOverflow::Overflow| TransferError::Overflow)
830    }
831}
832
833impl<T> InverseTransferFunction for AffineCalibration<T>
834where
835    T: InverseTransferFunction<Physical = i32>,
836{
837    type Physical = i32;
838    type Observation = T::Observation;
839
840    /// Undo the calibration, then invert the inner transfer.
841    ///
842    /// Range errors from the inner transfer are re-expressed in calibrated
843    /// units, so `minimum` / `maximum` are directly comparable with the value
844    /// the caller passed in. A calibration with `gain` and `scale` of opposite
845    /// signs reverses orientation, which turns an inner `BelowRange` into an
846    /// `AboveRange` and vice versa.
847    ///
848    /// When `|scale| > |gain|`, undoing the affine can land just outside the
849    /// inner physical range, or one integer beyond `i32`, even though
850    /// `physical` is in that range's forward image (the classic
851    /// `invert(convert(endpoint))` compression case). Those values are clamped
852    /// to the verified inner endpoint before the second invert attempt so
853    /// in-domain calibrated setpoints never spuriously range-error or overflow.
854    /// A representable undone value outside the calibrated forward image still
855    /// reports [`InverseTransferError::BelowRange`] /
856    /// [`InverseTransferError::AboveRange`]; a genuinely unrepresentable one
857    /// reports [`InverseTransferError::Overflow`].
858    fn invert(&self, physical: i32) -> Result<T::Observation, InverseTransferError<i32>> {
859        let uncalibrated = match self.transform.unapply(physical) {
860            Ok(uncalibrated) => uncalibrated,
861            Err(AffineOverflow::Overflow) => {
862                return self.recover_inverse_overflow(physical);
863            }
864        };
865        match self.inner.invert(uncalibrated) {
866            Ok(observation) => Ok(observation),
867            Err(error) => self.recover_compressed_endpoint(physical, error),
868        }
869    }
870}
871
872impl<T> AffineCalibration<T> {
873    /// True when the calibration preserves the inner transfer's orientation.
874    const fn preserves_orientation(&self) -> bool {
875        (self.transform.gain() > 0) == (self.transform.scale() > 0)
876    }
877
878    /// Recover the representable endpoint when inverse rounding crossed the
879    /// `i32` boundary.
880    ///
881    /// [`AffineTransform::unapply`] intentionally reports scalar overflow in
882    /// this case. The wrapper has an additional guarantee, though: every
883    /// value produced by `convert` must remain invertible. A compressed
884    /// transform can map `i32::MAX` or `i32::MIN` to a representable value
885    /// whose nearest inverse is one half-quantum outside `i32`. Saturating
886    /// that mathematical result identifies the only inner endpoint that
887    /// could have produced the value.
888    fn recover_inverse_overflow(
889        &self,
890        physical: i32,
891    ) -> Result<T::Observation, InverseTransferError<i32>>
892    where
893        T: InverseTransferFunction<Physical = i32>,
894    {
895        // These operations cannot overflow i64 for i32 operands. Reuse the
896        // crate's single rounding helper so this classification cannot drift
897        // from AffineTransform::unapply.
898        let numerator = i64::from(physical) * i64::from(self.transform.scale())
899            - i64::from(self.transform.offset());
900        let uncalibrated = div_nearest_ties_away(numerator, i64::from(self.transform.gain()));
901        let endpoint = if uncalibrated > i64::from(i32::MAX) {
902            i32::MAX
903        } else if uncalibrated < i64::from(i32::MIN) {
904            i32::MIN
905        } else {
906            // AffineTransform::unapply returned Overflow, so a representable
907            // rounded result would violate its arithmetic contract.
908            return Err(InverseTransferError::Overflow);
909        };
910
911        // Only recover the exact image of the endpoint. Other physical values
912        // still represent a genuinely unrepresentable inverse and retain the
913        // scalar Overflow contract.
914        let calibrated_endpoint = self
915            .transform
916            .apply(endpoint)
917            .map_err(|AffineOverflow::Overflow| InverseTransferError::Overflow)?;
918        if physical != calibrated_endpoint {
919            return Err(InverseTransferError::Overflow);
920        }
921
922        match self.inner.invert(endpoint) {
923            Ok(observation) => Ok(observation),
924            Err(error) => self.recover_compressed_endpoint(physical, error),
925        }
926    }
927
928    /// Re-express an inner range error in calibrated units.
929    ///
930    /// `physical` is echoed back unchanged — it is what the caller supplied.
931    /// The bound is mapped forward through the affine, and the variant flips
932    /// when the calibration reverses orientation.
933    fn recalibrate_error(
934        &self,
935        physical: i32,
936        error: InverseTransferError<i32>,
937    ) -> InverseTransferError<i32> {
938        let (bound, was_low) = match error {
939            InverseTransferError::BelowRange { minimum, .. } => (minimum, true),
940            InverseTransferError::AboveRange { maximum, .. } => (maximum, false),
941            InverseTransferError::AmbiguousFlat { low, high, .. } => {
942                return InverseTransferError::AmbiguousFlat {
943                    physical,
944                    low,
945                    high,
946                };
947            }
948            InverseTransferError::Overflow => return InverseTransferError::Overflow,
949        };
950
951        let Ok(calibrated) = self.transform.apply(bound) else {
952            return InverseTransferError::Overflow;
953        };
954
955        if was_low == self.preserves_orientation() {
956            InverseTransferError::BelowRange {
957                physical,
958                minimum: calibrated,
959            }
960        } else {
961            InverseTransferError::AboveRange {
962                physical,
963                maximum: calibrated,
964            }
965        }
966    }
967
968    /// If compression/rounding pushed `unapply` past an inner endpoint while
969    /// `physical` is still inside the forward image, clamp to that endpoint
970    /// and retry; otherwise surface the recalibrated range error.
971    fn recover_compressed_endpoint(
972        &self,
973        physical: i32,
974        error: InverseTransferError<i32>,
975    ) -> Result<T::Observation, InverseTransferError<i32>>
976    where
977        T: InverseTransferFunction<Physical = i32>,
978    {
979        let bound = match error {
980            InverseTransferError::BelowRange { minimum, .. } => minimum,
981            InverseTransferError::AboveRange { maximum, .. } => maximum,
982            other => return Err(self.recalibrate_error(physical, other)),
983        };
984
985        let calibrated_error = self.recalibrate_error(physical, error);
986        let inside_forward_image = match calibrated_error {
987            InverseTransferError::BelowRange { minimum, .. } => physical >= minimum,
988            InverseTransferError::AboveRange { maximum, .. } => physical <= maximum,
989            _ => false,
990        };
991
992        if !inside_forward_image {
993            return Err(calibrated_error);
994        }
995
996        self.inner
997            .invert(bound)
998            .map_err(|retry_error| self.recalibrate_error(physical, retry_error))
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    extern crate std;
1005
1006    use super::*;
1007
1008    static INPUTS: [u16; 3] = [100, 200, 400];
1009    static OUTPUTS: [i32; 3] = [-1_000, 0, 2_000];
1010    static DECREASING: [i32; 3] = [2_000, 0, -1_000];
1011    static FLAT_OUTPUTS: [i32; 4] = [0, 10, 10, 20];
1012    static FLAT_INPUTS: [u16; 4] = [0, 10, 20, 30];
1013    static FULL_INPUTS: [u16; 2] = [0, u16::MAX];
1014    static FULL_INCREASING: [i32; 2] = [i32::MIN, i32::MAX];
1015    static FULL_DECREASING: [i32; 2] = [i32::MAX, i32::MIN];
1016
1017    #[test]
1018    fn exact_knots_and_binary_search() {
1019        let transfer =
1020            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1021        assert_eq!(transfer.convert(100), Ok(-1_000));
1022        assert_eq!(transfer.convert(200), Ok(0));
1023        assert_eq!(transfer.convert(400), Ok(2_000));
1024        assert_eq!(transfer.convert(150), Ok(-500));
1025        assert_eq!(transfer.convert(300), Ok(1_000));
1026    }
1027
1028    #[test]
1029    fn independent_boundary_behavior() {
1030        let transfer =
1031            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1032                .with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Error);
1033        assert_eq!(transfer.convert(99), Ok(-1_000));
1034        assert_eq!(
1035            transfer.convert(401),
1036            Err(TransferError::AboveDomain {
1037                input: 401,
1038                maximum: 400
1039            })
1040        );
1041    }
1042
1043    #[test]
1044    fn observation_guard_error_overrides_above_clamp() {
1045        let transfer =
1046            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1047                .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp)
1048                .with_observation_guard(65_535, ObservationGuardBehavior::Error);
1049        assert_eq!(
1050            transfer.observation_guard(),
1051            Some(ObservationGuard {
1052                code: 65_535,
1053                behavior: ObservationGuardBehavior::Error,
1054            })
1055        );
1056        assert_eq!(
1057            transfer.convert(65_535),
1058            Err(TransferError::RejectedObservation { input: 65_535 })
1059        );
1060        assert_eq!(transfer.convert(401), Ok(2_000));
1061        assert_eq!(transfer.convert(400), Ok(2_000));
1062        assert_eq!(transfer.convert(200), Ok(0));
1063    }
1064
1065    #[test]
1066    fn observation_guard_clamp_overrides_above_error() {
1067        let transfer =
1068            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1069                .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Error)
1070                .with_observation_guard(65_535, ObservationGuardBehavior::Clamp);
1071        assert_eq!(transfer.convert(65_535), Ok(2_000));
1072        assert_eq!(
1073            transfer.convert(401),
1074            Err(TransferError::AboveDomain {
1075                input: 401,
1076                maximum: 400
1077            })
1078        );
1079    }
1080
1081    #[test]
1082    fn observation_guard_absent_leaves_above_policy() {
1083        let clamped =
1084            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1085                .with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
1086        assert_eq!(clamped.observation_guard(), None);
1087        assert_eq!(clamped.convert(65_535), Ok(2_000));
1088        assert_eq!(clamped.convert(401), Ok(2_000));
1089
1090        let errored =
1091            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1092        assert_eq!(
1093            errored.convert(65_535),
1094            Err(TransferError::AboveDomain {
1095                input: 65_535,
1096                maximum: 400
1097            })
1098        );
1099        assert_eq!(
1100            errored.convert(401),
1101            Err(TransferError::AboveDomain {
1102                input: 401,
1103                maximum: 400
1104            })
1105        );
1106    }
1107
1108    #[test]
1109    fn observation_guard_does_not_change_inverse() {
1110        let error_above =
1111            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1112                .with_observation_guard(65_535, ObservationGuardBehavior::Error);
1113        assert_eq!(error_above.invert_physical(2_000), Ok(400));
1114        assert_eq!(
1115            error_above.invert_physical(2_001),
1116            Err(InverseTransferError::AboveRange {
1117                physical: 2_001,
1118                maximum: 2_000
1119            })
1120        );
1121
1122        let clamp_above =
1123            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1124                .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp)
1125                .with_observation_guard(65_535, ObservationGuardBehavior::Error);
1126        assert_eq!(clamp_above.invert_physical(3_000), Ok(400));
1127    }
1128
1129    #[test]
1130    fn observation_guard_rejects_code_inside_or_at_domain() {
1131        assert!(
1132            std::panic::catch_unwind(|| {
1133                PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1134                    .with_observation_guard(400, ObservationGuardBehavior::Error);
1135            })
1136            .is_err()
1137        );
1138        assert!(
1139            std::panic::catch_unwind(|| {
1140                PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1141                    .with_observation_guard(200, ObservationGuardBehavior::Clamp);
1142            })
1143            .is_err()
1144        );
1145    }
1146
1147    #[test]
1148    fn decreasing_signed_transfer() {
1149        let transfer =
1150            PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing);
1151        assert_eq!(transfer.convert(150), Ok(1_000));
1152        assert_eq!(transfer.convert(300), Ok(-500));
1153    }
1154
1155    #[test]
1156    fn signed_rounding_ties_away_from_zero() {
1157        assert_eq!(interpolate_segment(1, 0, 0, 2, 1), Ok(1));
1158        assert_eq!(interpolate_segment(1, 0, 0, 2, -1), Ok(-1));
1159        assert_eq!(interpolate_segment(1, 0, -10, 2, -9), Ok(-10));
1160        assert_eq!(interpolate_segment(1, 0, 10, 2, 9), Ok(10));
1161        assert_eq!(interpolate_segment(1, 0, 10, 3, 11), Ok(10));
1162        assert_eq!(interpolate_segment(2, 0, 10, 3, 11), Ok(11));
1163        assert_eq!(interpolate_segment(1, 0, -10, 3, -11), Ok(-10));
1164        assert_eq!(interpolate_segment(2, 0, -10, 3, -11), Ok(-11));
1165    }
1166
1167    #[test]
1168    fn full_integer_ranges_are_safe() {
1169        assert_eq!(
1170            interpolate_segment(0, 0, i32::MIN, u16::MAX, i32::MAX),
1171            Ok(i32::MIN)
1172        );
1173        assert_eq!(
1174            interpolate_segment(u16::MAX, 0, i32::MIN, u16::MAX, i32::MAX),
1175            Ok(i32::MAX)
1176        );
1177        assert_eq!(
1178            interpolate_segment(0, 0, i32::MAX, u16::MAX, i32::MIN),
1179            Ok(i32::MAX)
1180        );
1181        assert_eq!(
1182            interpolate_segment(u16::MAX, 0, i32::MAX, u16::MAX, i32::MIN),
1183            Ok(i32::MIN)
1184        );
1185    }
1186
1187    #[test]
1188    fn exhaustive_full_span_is_bounded_and_monotonic() {
1189        let mut previous_increasing = i32::MIN;
1190        let mut previous_decreasing = i32::MAX;
1191        for input in 0..=u16::MAX {
1192            let increasing = interpolate_segment(input, 0, i32::MIN, u16::MAX, i32::MAX).unwrap();
1193            let decreasing = interpolate_segment(input, 0, i32::MAX, u16::MAX, i32::MIN).unwrap();
1194            assert!(increasing >= previous_increasing);
1195            assert!(decreasing <= previous_decreasing);
1196            previous_increasing = increasing;
1197            previous_decreasing = decreasing;
1198        }
1199        assert_eq!(previous_increasing, i32::MAX);
1200        assert_eq!(previous_decreasing, i32::MIN);
1201    }
1202
1203    #[test]
1204    fn constructor_rejects_invalid_tables() {
1205        static DUPLICATE_INPUTS: [u16; 2] = [10, 10];
1206        static DESCENDING_OUTPUTS: [i32; 2] = [10, 0];
1207
1208        assert!(
1209            std::panic::catch_unwind(|| {
1210                PiecewiseLinearTransfer::new(
1211                    &DUPLICATE_INPUTS,
1212                    &DESCENDING_OUTPUTS,
1213                    MonotonicDirection::Decreasing,
1214                )
1215            })
1216            .is_err()
1217        );
1218        assert!(
1219            std::panic::catch_unwind(|| {
1220                PiecewiseLinearTransfer::new(
1221                    &[10, 20],
1222                    &DESCENDING_OUTPUTS,
1223                    MonotonicDirection::Increasing,
1224                )
1225            })
1226            .is_err()
1227        );
1228    }
1229
1230    #[test]
1231    fn standalone_interpolation_validates_arguments() {
1232        assert_eq!(
1233            interpolate_segment(10, 10, 0, 10, 1),
1234            Err(InterpolationError::InvalidSpan)
1235        );
1236        assert_eq!(
1237            interpolate_segment(9, 10, 0, 20, 1),
1238            Err(InterpolationError::OutsideSegment {
1239                input: 9,
1240                minimum: 10,
1241                maximum: 20
1242            })
1243        );
1244    }
1245
1246    #[test]
1247    fn standalone_inversion_validates_arguments() {
1248        assert_eq!(
1249            invert_segment(0, 10, 0, 10, 1),
1250            Err(InterpolationError::InvalidSpan)
1251        );
1252        assert_eq!(
1253            invert_segment(5, 10, 7, 20, 7),
1254            Err(InterpolationError::FlatSegment)
1255        );
1256        assert_eq!(
1257            invert_segment(21, 10, 0, 20, 20),
1258            Err(InterpolationError::OutsidePhysicalSpan {
1259                physical: 21,
1260                minimum: 0,
1261                maximum: 20
1262            })
1263        );
1264        // A decreasing segment reports its span low-to-high.
1265        assert_eq!(
1266            invert_segment(25, 10, 20, 20, 0),
1267            Err(InterpolationError::OutsidePhysicalSpan {
1268                physical: 25,
1269                minimum: 0,
1270                maximum: 20
1271            })
1272        );
1273    }
1274
1275    #[test]
1276    fn affine_identity_and_factory_scale() {
1277        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1278        let identity = AffineCalibration::new(base, 1, 0, 1).unwrap();
1279        assert_eq!(identity.convert(150), Ok(-500));
1280        assert_eq!(identity.gain(), 1);
1281        assert_eq!(identity.offset(), 0);
1282        assert_eq!(identity.scale(), 1);
1283        assert_eq!(identity.inner().convert(150), Ok(-500));
1284
1285        let cal = AffineCalibration::new(base, 1005, -120, 1000).unwrap();
1286        // (-500 * 1005 + -120) / 1000 = -502.62 → -503 (ties-away / nearest)
1287        assert_eq!(cal.convert(150), Ok(-503));
1288        // (0 * 1005 + -120) / 1000 = -0.12 → 0
1289        assert_eq!(cal.convert(200), Ok(0));
1290        // (2000 * 1005 + -120) / 1000 = 2009.88 → 2010
1291        assert_eq!(cal.convert(400), Ok(2_010));
1292    }
1293
1294    #[test]
1295    fn affine_rounding_ties_away_and_negative_scale() {
1296        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1297        let half_up = AffineCalibration::new(base, 1, 0, 2).unwrap();
1298        // 2000 / 2 = 1000 exact; 0 / 2 = 0; -1000 / 2 = -500
1299        assert_eq!(half_up.convert(400), Ok(1_000));
1300        assert_eq!(half_up.convert(200), Ok(0));
1301
1302        // Scalar ties live on AffineTransform; the wrapper must agree.
1303        let scalar = AffineTransform::new(1, 0, 2).unwrap();
1304        assert_eq!(scalar.apply(1), Ok(1));
1305        assert_eq!(scalar.apply(-1), Ok(-1));
1306        assert_eq!(scalar.apply(1), half_up.transform().apply(1));
1307        assert_eq!(AffineTransform::new(1, 0, -2).unwrap().apply(1), Ok(-1));
1308        assert_eq!(AffineTransform::new(1, 0, -2).unwrap().apply(-1), Ok(1));
1309        assert_eq!(scalar.apply(3), Ok(2));
1310        assert_eq!(scalar.apply(-3), Ok(-2));
1311    }
1312
1313    #[test]
1314    fn affine_propagates_domain_errors_and_rejects_overflow() {
1315        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1316        let cal = AffineCalibration::new(base, 1, 0, 1).unwrap();
1317        assert_eq!(
1318            cal.convert(99),
1319            Err(TransferError::BelowDomain {
1320                input: 99,
1321                minimum: 100
1322            })
1323        );
1324
1325        let guarded =
1326            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1327                .with_observation_guard(65_535, ObservationGuardBehavior::Error);
1328        let guarded_cal = AffineCalibration::new(guarded, 1, 0, 1).unwrap();
1329        assert_eq!(
1330            guarded_cal.convert(65_535),
1331            Err(TransferError::RejectedObservation { input: 65_535 })
1332        );
1333
1334        // Large gain maps an in-domain knot outside i32.
1335        let overflow = AffineCalibration::new(base, i32::MAX, 0, 1).unwrap();
1336        assert_eq!(overflow.convert(400), Err(TransferError::Overflow));
1337        assert_eq!(
1338            AffineTransform::new(2, 0, 1).unwrap().apply(i32::MAX),
1339            Err(AffineOverflow::Overflow)
1340        );
1341        assert_eq!(
1342            overflow.transform().apply(2_000),
1343            Err(AffineOverflow::Overflow)
1344        );
1345    }
1346
1347    #[test]
1348    fn standalone_inversion_matches_the_table_path() {
1349        // Same knots the increasing fixture table uses for its first segment.
1350        for physical in -1_000..=0 {
1351            assert_eq!(
1352                invert_segment(physical, 100, -1_000, 200, 0),
1353                Ok(invert_valid_segment(physical, 100, -1_000, 200, 0)),
1354                "physical {physical}"
1355            );
1356        }
1357        assert_eq!(invert_segment(-500, 100, -1_000, 200, 0), Ok(150));
1358        assert_eq!(invert_segment(1_000, 200, 0, 400, 2_000), Ok(300));
1359    }
1360
1361    #[test]
1362    fn invert_exact_knots_and_midpoints() {
1363        let transfer =
1364            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1365        assert_eq!(transfer.invert_physical(-1_000), Ok(100));
1366        assert_eq!(transfer.invert_physical(0), Ok(200));
1367        assert_eq!(transfer.invert_physical(2_000), Ok(400));
1368        assert_eq!(transfer.invert_physical(-500), Ok(150));
1369        assert_eq!(transfer.invert_physical(1_000), Ok(300));
1370    }
1371
1372    #[test]
1373    fn invert_decreasing_maps_by_physical_range() {
1374        let transfer =
1375            PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing)
1376                .with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
1377        assert_eq!(transfer.invert_physical(1_000), Ok(150));
1378        assert_eq!(transfer.invert_physical(-500), Ok(300));
1379        // Below physical min (-1000) clamps to the high-input endpoint.
1380        assert_eq!(transfer.invert_physical(-2_000), Ok(400));
1381        // Above physical max (2000) clamps to the low-input endpoint.
1382        assert_eq!(transfer.invert_physical(3_000), Ok(100));
1383    }
1384
1385    #[test]
1386    fn boundary_policy_agrees_between_directions_on_a_decreasing_table() {
1387        // NTC-shaped: decreasing, error under domain_min, clamp over domain_max.
1388        let transfer =
1389            PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing)
1390                .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp);
1391
1392        // `above` governs codes over domain_max, which are exactly the codes
1393        // producing physical values under range_min. Both directions clamp.
1394        assert_eq!(transfer.convert(500), Ok(-1_000));
1395        assert_eq!(transfer.invert_physical(-2_000), Ok(400));
1396
1397        // `below` governs codes under domain_min, which produce physical
1398        // values over range_max. Both directions error.
1399        assert_eq!(
1400            transfer.convert(99),
1401            Err(TransferError::BelowDomain {
1402                input: 99,
1403                minimum: 100
1404            })
1405        );
1406        assert_eq!(
1407            transfer.invert_physical(3_000),
1408            Err(InverseTransferError::AboveRange {
1409                physical: 3_000,
1410                maximum: 2_000
1411            })
1412        );
1413
1414        assert_eq!(
1415            transfer.range_behaviors(),
1416            (BoundaryBehavior::Clamp, BoundaryBehavior::Error)
1417        );
1418    }
1419
1420    #[test]
1421    fn boundary_policy_is_unswapped_on_an_increasing_table() {
1422        let transfer =
1423            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1424                .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp);
1425
1426        assert_eq!(
1427            transfer.range_behaviors(),
1428            (BoundaryBehavior::Error, BoundaryBehavior::Clamp)
1429        );
1430        assert_eq!(transfer.convert(500), Ok(2_000));
1431        assert_eq!(transfer.invert_physical(3_000), Ok(400));
1432        assert_eq!(
1433            transfer.invert_physical(-2_000),
1434            Err(InverseTransferError::BelowRange {
1435                physical: -2_000,
1436                minimum: -1_000
1437            })
1438        );
1439    }
1440
1441    #[test]
1442    fn invert_range_errors_use_physical_bounds() {
1443        let transfer =
1444            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1445        assert_eq!(
1446            transfer.invert(-1_001),
1447            Err(InverseTransferError::BelowRange {
1448                physical: -1_001,
1449                minimum: -1_000
1450            })
1451        );
1452        assert_eq!(
1453            transfer.invert(2_001),
1454            Err(InverseTransferError::AboveRange {
1455                physical: 2_001,
1456                maximum: 2_000
1457            })
1458        );
1459    }
1460
1461    #[test]
1462    fn flat_resolution_policies() {
1463        let base = PiecewiseLinearTransfer::new(
1464            &FLAT_INPUTS,
1465            &FLAT_OUTPUTS,
1466            MonotonicDirection::Increasing,
1467        );
1468
1469        assert_eq!(
1470            base.with_flat_resolution(FlatResolution::PreferLowInput)
1471                .invert(10),
1472            Ok(10)
1473        );
1474        assert_eq!(
1475            base.with_flat_resolution(FlatResolution::PreferHighInput)
1476                .invert(10),
1477            Ok(20)
1478        );
1479        assert_eq!(
1480            base.with_flat_resolution(FlatResolution::Midpoint)
1481                .invert(10),
1482            Ok(15)
1483        );
1484        assert_eq!(
1485            base.with_flat_resolution(FlatResolution::Error).invert(10),
1486            Err(InverseTransferError::AmbiguousFlat {
1487                physical: 10,
1488                low: 10,
1489                high: 20
1490            })
1491        );
1492        // Unique knot: FlatResolution::Error is unused.
1493        assert_eq!(
1494            base.with_flat_resolution(FlatResolution::Error).invert(0),
1495            Ok(0)
1496        );
1497        assert_eq!(base.invert(5), Ok(5));
1498    }
1499
1500    #[test]
1501    fn invert_segment_rounding_ties_away() {
1502        // Forward: input 1 on [0,2] with y 0→1 rounds to 1.
1503        // Inverse of that physical should prefer the observation side of the tie.
1504        assert_eq!(invert_valid_segment(1, 0, 0, 2, 2), 1);
1505        assert_eq!(invert_valid_segment(-1, 0, 0, 2, -2), 1);
1506        // Half-quantum ties away from zero along the input axis from x0.
1507        assert_eq!(invert_valid_segment(1, 0, 0, 4, 2), 2);
1508        assert_eq!(invert_valid_segment(-1, 0, 0, 4, -2), 2);
1509    }
1510
1511    #[test]
1512    fn round_trip_invert_convert_on_non_flat_table() {
1513        let transfer =
1514            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1515        for input in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
1516            let physical = transfer.convert(input).unwrap();
1517            let recovered = transfer.invert(physical).unwrap();
1518            let distance = i32::from(recovered).abs_diff(i32::from(input));
1519            assert!(
1520                distance <= 1,
1521                "input {input}: invert(convert) -> {recovered} (Δ={distance})"
1522            );
1523        }
1524    }
1525
1526    #[test]
1527    fn div_nearest_ties_away_matches_forward_policy() {
1528        assert_eq!(div_nearest_ties_away(1, 2), 1);
1529        assert_eq!(div_nearest_ties_away(-1, 2), -1);
1530        assert_eq!(div_nearest_ties_away(1, -2), -1);
1531        assert_eq!(div_nearest_ties_away(-1, -2), 1);
1532        assert_eq!(div_nearest_ties_away(3, 2), 2);
1533        assert_eq!(div_nearest_ties_away(-3, 2), -2);
1534    }
1535
1536    #[test]
1537    fn affine_constructor_returns_error_for_zero_scale() {
1538        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1539        assert!(matches!(
1540            AffineCalibration::new(base, 1, 0, 0),
1541            Err(AffineCalibrationError::ZeroScale)
1542        ));
1543    }
1544
1545    #[test]
1546    fn affine_nesting_composes() {
1547        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1548        let inner = AffineCalibration::new(base, 2, 10, 1).unwrap();
1549        let outer = AffineCalibration::new(inner, 1, -10, 2).unwrap();
1550        // y=0 → (0*2+10)=10 → (10-10)/2 = 0
1551        assert_eq!(outer.convert(200), Ok(0));
1552        // y=2000 → 4010 → (4010-10)/2 = 2000
1553        assert_eq!(outer.convert(400), Ok(2_000));
1554    }
1555
1556    #[test]
1557    fn affine_constructor_returns_error_for_zero_gain() {
1558        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1559        assert!(matches!(
1560            AffineCalibration::new(base, 0, 5, 1),
1561            Err(AffineCalibrationError::ZeroGain)
1562        ));
1563    }
1564
1565    #[test]
1566    fn calibrated_inverse_round_trips_through_the_table() {
1567        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1568        let cal = AffineCalibration::new(base, 1_005, -120, 1_000).unwrap();
1569
1570        // Every in-domain code survives convert-then-invert on this table.
1571        for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
1572            let calibrated = cal.convert(code).unwrap();
1573            let recovered = cal.invert(calibrated).unwrap();
1574            assert!(
1575                recovered.abs_diff(code) <= 1,
1576                "code {code}: convert -> {calibrated} -> invert -> {recovered}"
1577            );
1578        }
1579    }
1580
1581    #[test]
1582    fn calibrated_inverse_survives_compressing_calibration_at_endpoints() {
1583        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1584        // |scale| > |gain|: unapply expands and can overshoot an inner endpoint
1585        // even when the calibrated value is exactly convert(endpoint).
1586        let cal = AffineCalibration::new(base, 2, 0, 3).unwrap();
1587
1588        let low = cal.convert(100).unwrap();
1589        assert_eq!(low, -667);
1590        assert_eq!(cal.invert(low), Ok(100));
1591
1592        let high = cal.convert(400).unwrap();
1593        assert_eq!(high, 1_333);
1594        assert_eq!(cal.invert(high), Ok(400));
1595
1596        for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
1597            let calibrated = cal.convert(code).unwrap();
1598            let recovered = cal.invert(calibrated).unwrap();
1599            assert!(
1600                recovered.abs_diff(code) <= 1,
1601                "code {code}: convert -> {calibrated} -> invert -> {recovered}"
1602            );
1603        }
1604
1605        // Truly outside the calibrated forward image still range-errors.
1606        assert_eq!(
1607            cal.invert(-668),
1608            Err(InverseTransferError::BelowRange {
1609                physical: -668,
1610                minimum: -667
1611            })
1612        );
1613        assert_eq!(
1614            cal.invert(1_334),
1615            Err(InverseTransferError::AboveRange {
1616                physical: 1_334,
1617                maximum: 1_333
1618            })
1619        );
1620    }
1621
1622    #[test]
1623    fn calibrated_inverse_recovers_i32_endpoints_after_scalar_inverse_overflow() {
1624        let tables = [
1625            (&FULL_INCREASING, MonotonicDirection::Increasing),
1626            (&FULL_DECREASING, MonotonicDirection::Decreasing),
1627        ];
1628
1629        // Exercise preserved/reversed affine orientation and offsets that can
1630        // push nearest inverse rounding past either signed endpoint.
1631        for (outputs, direction) in tables {
1632            let base = PiecewiseLinearTransfer::new(&FULL_INPUTS, outputs, direction);
1633            for gain in [2, -2] {
1634                for scale in [3, -3] {
1635                    for offset in [-1, 0, 1] {
1636                        let cal = AffineCalibration::new(base, gain, offset, scale).unwrap();
1637                        for code in [0, u16::MAX] {
1638                            let calibrated = cal.convert(code).unwrap();
1639                            assert_eq!(
1640                                cal.invert(calibrated),
1641                                Ok(code),
1642                                "code={code} converted={calibrated} gain={gain} offset={offset} scale={scale} direction={direction:?}"
1643                            );
1644                        }
1645                    }
1646                }
1647            }
1648        }
1649
1650        // Keep the scalar primitive's intentional overflow behavior explicit:
1651        // the endpoint recovery belongs only to the transfer wrapper.
1652        let scalar = AffineTransform::new(2, 0, 3).unwrap();
1653        let converted = scalar.apply(i32::MAX).unwrap();
1654        assert_eq!(scalar.unapply(converted), Err(AffineOverflow::Overflow));
1655
1656        let scalar = AffineTransform::new(2, -1, 3).unwrap();
1657        let converted = scalar.apply(i32::MIN).unwrap();
1658        assert_eq!(scalar.unapply(converted), Err(AffineOverflow::Overflow));
1659    }
1660
1661    #[test]
1662    fn compressing_calibration_still_flips_orientation_on_range_errors() {
1663        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1664        let flipped = AffineCalibration::new(base, -2, 0, 3).unwrap();
1665        assert!(!flipped.preserves_orientation());
1666
1667        let low_obs = flipped.convert(100).unwrap();
1668        let high_obs = flipped.convert(400).unwrap();
1669        assert_eq!(flipped.invert(low_obs), Ok(100));
1670        assert_eq!(flipped.invert(high_obs), Ok(400));
1671
1672        // Past the calibrated image of the former low endpoint → AboveRange.
1673        assert_eq!(
1674            flipped.invert(low_obs + 1),
1675            Err(InverseTransferError::AboveRange {
1676                physical: low_obs + 1,
1677                maximum: low_obs
1678            })
1679        );
1680        assert_eq!(
1681            flipped.invert(high_obs - 1),
1682            Err(InverseTransferError::BelowRange {
1683                physical: high_obs - 1,
1684                minimum: high_obs
1685            })
1686        );
1687    }
1688
1689    #[test]
1690    fn calibrated_inverse_undoes_the_affine_before_the_table() {
1691        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1692        let identity = AffineCalibration::new(base, 1, 0, 1).unwrap();
1693        assert_eq!(identity.invert(-500), Ok(150));
1694        assert_eq!(identity.invert(0), Ok(200));
1695
1696        // Scale by ten: a calibrated -5000 is an uncalibrated -500.
1697        let scaled = AffineCalibration::new(base, 10, 0, 1).unwrap();
1698        assert_eq!(scaled.invert(-5_000), Ok(150));
1699        assert_eq!(scaled.convert(150), Ok(-5_000));
1700    }
1701
1702    #[test]
1703    fn calibrated_inverse_reports_bounds_in_calibrated_units() {
1704        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1705        // Uncalibrated range is -1000..=2000; at gain 10 that is -10000..=20000.
1706        let cal = AffineCalibration::new(base, 10, 0, 1).unwrap();
1707
1708        // Within half an uncalibrated quantum of the bound, undoing the affine
1709        // rounds back into range rather than failing: -10_001 / 10 is -1000.1,
1710        // which is the endpoint knot.
1711        assert_eq!(cal.invert(-10_001), Ok(100));
1712        assert_eq!(cal.invert(20_001), Ok(400));
1713
1714        // Past that, the bound is reported in calibrated units so the caller
1715        // can compare it against the value they passed in.
1716        assert_eq!(
1717            cal.invert(-10_010),
1718            Err(InverseTransferError::BelowRange {
1719                physical: -10_010,
1720                minimum: -10_000
1721            })
1722        );
1723        assert_eq!(
1724            cal.invert(20_010),
1725            Err(InverseTransferError::AboveRange {
1726                physical: 20_010,
1727                maximum: 20_000
1728            })
1729        );
1730    }
1731
1732    #[test]
1733    fn calibrated_inverse_flips_variants_when_orientation_reverses() {
1734        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1735        // Negative gain reverses sense: uncalibrated -1000..=2000 becomes
1736        // calibrated -2000..=1000, so the inner low bound is the high one here.
1737        let flipped = AffineCalibration::new(base, -1, 0, 1).unwrap();
1738        assert!(!flipped.preserves_orientation());
1739        assert_eq!(flipped.convert(100), Ok(1_000));
1740        assert_eq!(flipped.convert(400), Ok(-2_000));
1741        assert_eq!(flipped.invert(1_000), Ok(100));
1742        assert_eq!(flipped.invert(-2_000), Ok(400));
1743
1744        // The inner transfer reports BelowRange; calibrated, it is AboveRange.
1745        assert_eq!(
1746            flipped.invert(1_001),
1747            Err(InverseTransferError::AboveRange {
1748                physical: 1_001,
1749                maximum: 1_000
1750            })
1751        );
1752        assert_eq!(
1753            flipped.invert(-2_001),
1754            Err(InverseTransferError::BelowRange {
1755                physical: -2_001,
1756                minimum: -2_000
1757            })
1758        );
1759    }
1760
1761    #[test]
1762    fn calibrated_inverse_honors_clamp_and_flat_policy() {
1763        let clamped =
1764            PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
1765                .with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
1766        let cal = AffineCalibration::new(clamped, 10, 0, 1).unwrap();
1767        assert_eq!(cal.invert(-99_999), Ok(100));
1768        assert_eq!(cal.invert(99_999), Ok(400));
1769
1770        let flat = PiecewiseLinearTransfer::new(
1771            &FLAT_INPUTS,
1772            &FLAT_OUTPUTS,
1773            MonotonicDirection::Increasing,
1774        )
1775        .with_flat_resolution(FlatResolution::Error);
1776        let cal = AffineCalibration::new(flat, 2, 0, 1).unwrap();
1777        // Flat run sits at uncalibrated 10, i.e. calibrated 20.
1778        assert_eq!(
1779            cal.invert(20),
1780            Err(InverseTransferError::AmbiguousFlat {
1781                physical: 20,
1782                low: 10,
1783                high: 20
1784            })
1785        );
1786    }
1787
1788    #[test]
1789    fn calibrated_inverse_rejects_unrepresentable_input() {
1790        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1791        // Undoing a large scale pushes the uncalibrated value outside i32.
1792        let cal = AffineCalibration::new(base, 1, 0, i32::MAX).unwrap();
1793        assert_eq!(cal.invert(i32::MAX), Err(InverseTransferError::Overflow));
1794    }
1795
1796    #[test]
1797    fn nested_calibration_inverts_through_every_layer() {
1798        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1799        let inner = AffineCalibration::new(base, 2, 10, 1).unwrap();
1800        let outer = AffineCalibration::new(inner, 1, -10, 2).unwrap();
1801        assert_eq!(outer.convert(200), Ok(0));
1802        assert_eq!(outer.invert(0), Ok(200));
1803        assert_eq!(outer.convert(400), Ok(2_000));
1804        assert_eq!(outer.invert(2_000), Ok(400));
1805    }
1806
1807    #[test]
1808    fn affine_calibration_convert_matches_direct_transform_apply() {
1809        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1810        let cases = [
1811            (1, 0, 1),
1812            (1_005, -120, 1_000),
1813            (2, 0, 3),
1814            (-2, 0, 3),
1815            (10, 0, 1),
1816            (-1, 0, 1),
1817        ];
1818
1819        for (gain, offset, scale) in cases {
1820            let cal = AffineCalibration::new(base, gain, offset, scale).unwrap();
1821            assert_eq!(cal.transform().gain(), cal.gain());
1822            assert_eq!(cal.transform().offset(), cal.offset());
1823            assert_eq!(cal.transform().scale(), cal.scale());
1824
1825            for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
1826                let from_wrapper = cal.convert(code).unwrap();
1827                let inner = cal.inner().convert(code).unwrap();
1828                let from_scalar = cal.transform().apply(inner).unwrap();
1829                assert_eq!(
1830                    from_wrapper, from_scalar,
1831                    "code {code}: convert={from_wrapper} apply={from_scalar} (gain={gain} offset={offset} scale={scale})"
1832                );
1833            }
1834        }
1835    }
1836
1837    #[test]
1838    fn affine_calibration_invert_matches_unapply_then_inner() {
1839        let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
1840        let cal = AffineCalibration::new(base, 10, 0, 1).unwrap();
1841
1842        // Direct unapply then inner invert agrees with the wrapper for in-image values.
1843        let calibrated = cal.convert(150).unwrap();
1844        let uncalibrated = cal.transform().unapply(calibrated).unwrap();
1845        assert_eq!(cal.inner().invert(uncalibrated), Ok(150));
1846        assert_eq!(cal.invert(calibrated), Ok(150));
1847
1848        // Compressing calibration: unapply of convert(endpoint) can overshoot,
1849        // and the wrapper recovers by clamping. Direct unapply does not.
1850        let compressed = AffineCalibration::new(base, 2, 0, 3).unwrap();
1851        let low = compressed.convert(100).unwrap();
1852        assert_eq!(low, -667);
1853        let undone = compressed.transform().unapply(low).unwrap();
1854        assert!(undone < -1_000, "unapply overshoots inner min: {undone}");
1855        assert_eq!(
1856            compressed.inner().invert(undone),
1857            Err(InverseTransferError::BelowRange {
1858                physical: undone,
1859                minimum: -1_000,
1860            })
1861        );
1862        assert_eq!(compressed.invert(low), Ok(100));
1863
1864        // Orientation reversal: the wrapper flips range variants after unapply.
1865        let flipped = AffineCalibration::new(base, -1, 0, 1).unwrap();
1866        let high_calibrated = flipped.convert(100).unwrap();
1867        assert_eq!(high_calibrated, 1_000);
1868        assert_eq!(flipped.transform().unapply(1_001).unwrap(), -1_001);
1869        assert_eq!(
1870            flipped.invert(1_001),
1871            Err(InverseTransferError::AboveRange {
1872                physical: 1_001,
1873                maximum: 1_000
1874            })
1875        );
1876    }
1877}