use crate::affine::{AffineOverflow, AffineTransform, AffineTransformError};
use crate::round::div_nearest_ties_away;
pub trait TransferFunction {
type Input: Copy;
type Output: Copy;
fn convert(&self, input: Self::Input) -> Result<Self::Output, TransferError<Self::Input>>;
}
pub trait InverseTransferFunction {
type Physical: Copy;
type Observation: Copy;
fn invert(
&self,
physical: Self::Physical,
) -> Result<Self::Observation, InverseTransferError<Self::Physical>>;
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MonotonicDirection {
Increasing,
Decreasing,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum BoundaryBehavior {
Error,
Clamp,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ObservationGuardBehavior {
Error,
Clamp,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ObservationGuard {
pub code: u16,
pub behavior: ObservationGuardBehavior,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ObservationGuardMetadata {
pub code: u16,
pub behavior: ObservationGuardBehavior,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
pub enum FlatResolution {
#[default]
PreferLowInput,
PreferHighInput,
Midpoint,
Error,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum TransferError<I> {
BelowDomain {
input: I,
minimum: I,
},
AboveDomain {
input: I,
maximum: I,
},
RejectedObservation {
input: I,
},
Overflow,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum InverseTransferError<P> {
BelowRange {
physical: P,
minimum: P,
},
AboveRange {
physical: P,
maximum: P,
},
AmbiguousFlat {
physical: P,
low: u16,
high: u16,
},
Overflow,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum InterpolationError {
InvalidSpan,
OutsideSegment {
input: u16,
minimum: u16,
maximum: u16,
},
FlatSegment,
OutsidePhysicalSpan {
physical: i32,
minimum: i32,
maximum: i32,
},
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct TransferMetadata {
pub input_unit: &'static str,
pub output_unit: &'static str,
pub output_scale: u32,
pub domain_min: u16,
pub domain_max: u16,
pub range_min: i32,
pub range_max: i32,
pub direction: MonotonicDirection,
pub knot_count: usize,
pub strictly_monotonic: bool,
pub flat_segment_count: usize,
pub requested_max_error: u32,
pub achieved_max_error: u32,
pub worst_case_input: u16,
pub achieved_max_inverse_code_error: u16,
}
#[derive(Copy, Clone, Debug)]
pub struct PiecewiseLinearTransfer<const N: usize> {
inputs: &'static [u16; N],
outputs: &'static [i32; N],
direction: MonotonicDirection,
below: BoundaryBehavior,
above: BoundaryBehavior,
flat_resolution: FlatResolution,
observation_guard: Option<ObservationGuard>,
}
impl<const N: usize> PiecewiseLinearTransfer<N> {
pub const fn new(
inputs: &'static [u16; N],
outputs: &'static [i32; N],
direction: MonotonicDirection,
) -> Self {
assert!(N >= 2);
let mut index = 1;
while index < N {
assert!(inputs[index] > inputs[index - 1]);
match direction {
MonotonicDirection::Increasing => {
assert!(outputs[index] >= outputs[index - 1]);
}
MonotonicDirection::Decreasing => {
assert!(outputs[index] <= outputs[index - 1]);
}
}
index += 1;
}
Self {
inputs,
outputs,
direction,
below: BoundaryBehavior::Error,
above: BoundaryBehavior::Error,
flat_resolution: FlatResolution::PreferLowInput,
observation_guard: None,
}
}
pub const fn with_boundaries(
mut self,
below: BoundaryBehavior,
above: BoundaryBehavior,
) -> Self {
self.below = below;
self.above = above;
self
}
pub const fn with_flat_resolution(mut self, policy: FlatResolution) -> Self {
self.flat_resolution = policy;
self
}
pub const fn with_observation_guard(
mut self,
code: u16,
behavior: ObservationGuardBehavior,
) -> Self {
assert!(
code > self.inputs[N - 1],
"observation guard code must be strictly above domain_max"
);
self.observation_guard = Some(ObservationGuard { code, behavior });
self
}
pub const fn observation_guard(&self) -> Option<ObservationGuard> {
self.observation_guard
}
pub const fn inputs(&self) -> &'static [u16; N] {
self.inputs
}
pub const fn outputs(&self) -> &'static [i32; N] {
self.outputs
}
pub const fn direction(&self) -> MonotonicDirection {
self.direction
}
pub const fn below_behavior(&self) -> BoundaryBehavior {
self.below
}
pub const fn above_behavior(&self) -> BoundaryBehavior {
self.above
}
pub const fn flat_resolution(&self) -> FlatResolution {
self.flat_resolution
}
pub const fn domain(&self) -> (u16, u16) {
(self.inputs[0], self.inputs[N - 1])
}
pub const fn physical_range(&self) -> (i32, i32) {
let first = self.outputs[0];
let last = self.outputs[N - 1];
if first <= last {
(first, last)
} else {
(last, first)
}
}
pub fn invert_physical(&self, physical: i32) -> Result<u16, InverseTransferError<i32>> {
InverseTransferFunction::invert(self, physical)
}
pub const fn range_behaviors(&self) -> (BoundaryBehavior, BoundaryBehavior) {
match self.direction {
MonotonicDirection::Increasing => (self.below, self.above),
MonotonicDirection::Decreasing => (self.above, self.below),
}
}
fn observation_at_physical_end(&self, low_physical: bool) -> u16 {
match (self.direction, low_physical) {
(MonotonicDirection::Increasing, true) | (MonotonicDirection::Decreasing, false) => {
self.inputs[0]
}
(MonotonicDirection::Increasing, false) | (MonotonicDirection::Decreasing, true) => {
self.inputs[N - 1]
}
}
}
fn resolve_flat_run(
&self,
physical: i32,
left: usize,
right: usize,
) -> Result<u16, InverseTransferError<i32>> {
let low = self.inputs[left];
let high = self.inputs[right];
if left == right {
return Ok(low);
}
match self.flat_resolution {
FlatResolution::PreferLowInput => Ok(low),
FlatResolution::PreferHighInput => Ok(high),
FlatResolution::Midpoint => Ok(low + (high - low) / 2),
FlatResolution::Error => Err(InverseTransferError::AmbiguousFlat {
physical,
low,
high,
}),
}
}
fn expand_flat_run(&self, index: usize) -> (usize, usize) {
let value = self.outputs[index];
let mut left = index;
while left > 0 && self.outputs[left - 1] == value {
left -= 1;
}
let mut right = index;
while right + 1 < N && self.outputs[right + 1] == value {
right += 1;
}
(left, right)
}
fn largest_index_at_or_past(&self, physical: i32) -> usize {
let mut low = 0usize;
let mut high = N - 1;
while low < high {
let middle = low + (high - low).div_ceil(2);
let past = match self.direction {
MonotonicDirection::Increasing => self.outputs[middle] <= physical,
MonotonicDirection::Decreasing => self.outputs[middle] >= physical,
};
if past {
low = middle;
} else {
high = middle - 1;
}
}
low
}
}
impl<const N: usize> TransferFunction for PiecewiseLinearTransfer<N> {
type Input = u16;
type Output = i32;
fn convert(&self, input: u16) -> Result<i32, TransferError<u16>> {
let minimum = self.inputs[0];
let maximum = self.inputs[N - 1];
if let Some(guard) = self.observation_guard
&& input == guard.code
{
return match guard.behavior {
ObservationGuardBehavior::Error => {
Err(TransferError::RejectedObservation { input })
}
ObservationGuardBehavior::Clamp => Ok(self.outputs[N - 1]),
};
}
if input < minimum {
return match self.below {
BoundaryBehavior::Error => Err(TransferError::BelowDomain { input, minimum }),
BoundaryBehavior::Clamp => Ok(self.outputs[0]),
};
}
if input > maximum {
return match self.above {
BoundaryBehavior::Error => Err(TransferError::AboveDomain { input, maximum }),
BoundaryBehavior::Clamp => Ok(self.outputs[N - 1]),
};
}
if input == minimum {
return Ok(self.outputs[0]);
}
if input == maximum {
return Ok(self.outputs[N - 1]);
}
let mut low = 0usize;
let mut high = N - 1;
while low + 1 < high {
let middle = low + (high - low) / 2;
match input.cmp(&self.inputs[middle]) {
core::cmp::Ordering::Less => high = middle,
core::cmp::Ordering::Equal => return Ok(self.outputs[middle]),
core::cmp::Ordering::Greater => low = middle,
}
}
Ok(interpolate_valid_segment(
input,
self.inputs[low],
self.outputs[low],
self.inputs[high],
self.outputs[high],
))
}
}
impl<const N: usize> InverseTransferFunction for PiecewiseLinearTransfer<N> {
type Physical = i32;
type Observation = u16;
fn invert(&self, physical: i32) -> Result<u16, InverseTransferError<i32>> {
let (minimum, maximum) = self.physical_range();
let (low_physical, high_physical) = self.range_behaviors();
if physical < minimum {
return match low_physical {
BoundaryBehavior::Error => {
Err(InverseTransferError::BelowRange { physical, minimum })
}
BoundaryBehavior::Clamp => Ok(self.observation_at_physical_end(true)),
};
}
if physical > maximum {
return match high_physical {
BoundaryBehavior::Error => {
Err(InverseTransferError::AboveRange { physical, maximum })
}
BoundaryBehavior::Clamp => Ok(self.observation_at_physical_end(false)),
};
}
let index = self.largest_index_at_or_past(physical);
if self.outputs[index] == physical {
let (left, right) = self.expand_flat_run(index);
return self.resolve_flat_run(physical, left, right);
}
debug_assert!(index + 1 < N);
Ok(invert_valid_segment(
physical,
self.inputs[index],
self.outputs[index],
self.inputs[index + 1],
self.outputs[index + 1],
))
}
}
pub fn interpolate_segment(
input: u16,
x0: u16,
y0: i32,
x1: u16,
y1: i32,
) -> Result<i32, InterpolationError> {
if x1 <= x0 {
return Err(InterpolationError::InvalidSpan);
}
if input < x0 || input > x1 {
return Err(InterpolationError::OutsideSegment {
input,
minimum: x0,
maximum: x1,
});
}
Ok(interpolate_valid_segment(input, x0, y0, x1, y1))
}
pub fn invert_segment(
physical: i32,
x0: u16,
y0: i32,
x1: u16,
y1: i32,
) -> Result<u16, InterpolationError> {
if x1 <= x0 {
return Err(InterpolationError::InvalidSpan);
}
if y0 == y1 {
return Err(InterpolationError::FlatSegment);
}
let (minimum, maximum) = if y0 < y1 { (y0, y1) } else { (y1, y0) };
if physical < minimum || physical > maximum {
return Err(InterpolationError::OutsidePhysicalSpan {
physical,
minimum,
maximum,
});
}
Ok(invert_valid_segment(physical, x0, y0, x1, y1))
}
fn interpolate_valid_segment(input: u16, x0: u16, y0: i32, x1: u16, y1: i32) -> i32 {
let offset = i64::from(input - x0);
let span = i64::from(x1 - x0);
let delta = i64::from(y1) - i64::from(y0);
let numerator = i64::from(y0) * span + delta * offset;
let result = div_nearest_ties_away(numerator, span);
debug_assert!((i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&result));
result as i32
}
fn invert_valid_segment(physical: i32, x0: u16, y0: i32, x1: u16, y1: i32) -> u16 {
let dy = i64::from(y1) - i64::from(y0);
debug_assert!(dy != 0);
let dx = i64::from(x1) - i64::from(x0);
let numerator = i64::from(x0) * dy + (i64::from(physical) - i64::from(y0)) * dx;
let result = div_nearest_ties_away(numerator, dy);
result.clamp(i64::from(x0), i64::from(x1)) as u16
}
#[derive(Copy, Clone, Debug)]
pub struct AffineCalibration<T> {
inner: T,
transform: AffineTransform,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AffineCalibrationError {
ZeroScale,
ZeroGain,
}
impl<T> AffineCalibration<T> {
pub fn new(
inner: T,
gain: i32,
offset: i32,
scale: i32,
) -> Result<Self, AffineCalibrationError> {
let transform = AffineTransform::new(gain, offset, scale).map_err(|error| match error {
AffineTransformError::ZeroScale => AffineCalibrationError::ZeroScale,
AffineTransformError::ZeroGain => AffineCalibrationError::ZeroGain,
})?;
Ok(Self { inner, transform })
}
pub const fn inner(&self) -> &T {
&self.inner
}
pub const fn transform(&self) -> AffineTransform {
self.transform
}
pub const fn gain(&self) -> i32 {
self.transform.gain()
}
pub const fn offset(&self) -> i32 {
self.transform.offset()
}
pub const fn scale(&self) -> i32 {
self.transform.scale()
}
}
impl<T> TransferFunction for AffineCalibration<T>
where
T: TransferFunction<Output = i32>,
{
type Input = T::Input;
type Output = i32;
fn convert(&self, input: Self::Input) -> Result<i32, TransferError<Self::Input>> {
let y = self.inner.convert(input)?;
self.transform
.apply(y)
.map_err(|AffineOverflow::Overflow| TransferError::Overflow)
}
}
impl<T> InverseTransferFunction for AffineCalibration<T>
where
T: InverseTransferFunction<Physical = i32>,
{
type Physical = i32;
type Observation = T::Observation;
fn invert(&self, physical: i32) -> Result<T::Observation, InverseTransferError<i32>> {
let uncalibrated = match self.transform.unapply(physical) {
Ok(uncalibrated) => uncalibrated,
Err(AffineOverflow::Overflow) => {
return self.recover_inverse_overflow(physical);
}
};
match self.inner.invert(uncalibrated) {
Ok(observation) => Ok(observation),
Err(error) => self.recover_compressed_endpoint(physical, error),
}
}
}
impl<T> AffineCalibration<T> {
const fn preserves_orientation(&self) -> bool {
(self.transform.gain() > 0) == (self.transform.scale() > 0)
}
fn recover_inverse_overflow(
&self,
physical: i32,
) -> Result<T::Observation, InverseTransferError<i32>>
where
T: InverseTransferFunction<Physical = i32>,
{
let numerator = i64::from(physical) * i64::from(self.transform.scale())
- i64::from(self.transform.offset());
let uncalibrated = div_nearest_ties_away(numerator, i64::from(self.transform.gain()));
let endpoint = if uncalibrated > i64::from(i32::MAX) {
i32::MAX
} else if uncalibrated < i64::from(i32::MIN) {
i32::MIN
} else {
return Err(InverseTransferError::Overflow);
};
let calibrated_endpoint = self
.transform
.apply(endpoint)
.map_err(|AffineOverflow::Overflow| InverseTransferError::Overflow)?;
if physical != calibrated_endpoint {
return Err(InverseTransferError::Overflow);
}
match self.inner.invert(endpoint) {
Ok(observation) => Ok(observation),
Err(error) => self.recover_compressed_endpoint(physical, error),
}
}
fn recalibrate_error(
&self,
physical: i32,
error: InverseTransferError<i32>,
) -> InverseTransferError<i32> {
let (bound, was_low) = match error {
InverseTransferError::BelowRange { minimum, .. } => (minimum, true),
InverseTransferError::AboveRange { maximum, .. } => (maximum, false),
InverseTransferError::AmbiguousFlat { low, high, .. } => {
return InverseTransferError::AmbiguousFlat {
physical,
low,
high,
};
}
InverseTransferError::Overflow => return InverseTransferError::Overflow,
};
let Ok(calibrated) = self.transform.apply(bound) else {
return InverseTransferError::Overflow;
};
if was_low == self.preserves_orientation() {
InverseTransferError::BelowRange {
physical,
minimum: calibrated,
}
} else {
InverseTransferError::AboveRange {
physical,
maximum: calibrated,
}
}
}
fn recover_compressed_endpoint(
&self,
physical: i32,
error: InverseTransferError<i32>,
) -> Result<T::Observation, InverseTransferError<i32>>
where
T: InverseTransferFunction<Physical = i32>,
{
let bound = match error {
InverseTransferError::BelowRange { minimum, .. } => minimum,
InverseTransferError::AboveRange { maximum, .. } => maximum,
other => return Err(self.recalibrate_error(physical, other)),
};
let calibrated_error = self.recalibrate_error(physical, error);
let inside_forward_image = match calibrated_error {
InverseTransferError::BelowRange { minimum, .. } => physical >= minimum,
InverseTransferError::AboveRange { maximum, .. } => physical <= maximum,
_ => false,
};
if !inside_forward_image {
return Err(calibrated_error);
}
self.inner
.invert(bound)
.map_err(|retry_error| self.recalibrate_error(physical, retry_error))
}
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
static INPUTS: [u16; 3] = [100, 200, 400];
static OUTPUTS: [i32; 3] = [-1_000, 0, 2_000];
static DECREASING: [i32; 3] = [2_000, 0, -1_000];
static FLAT_OUTPUTS: [i32; 4] = [0, 10, 10, 20];
static FLAT_INPUTS: [u16; 4] = [0, 10, 20, 30];
static FULL_INPUTS: [u16; 2] = [0, u16::MAX];
static FULL_INCREASING: [i32; 2] = [i32::MIN, i32::MAX];
static FULL_DECREASING: [i32; 2] = [i32::MAX, i32::MIN];
#[test]
fn exact_knots_and_binary_search() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert_eq!(transfer.convert(100), Ok(-1_000));
assert_eq!(transfer.convert(200), Ok(0));
assert_eq!(transfer.convert(400), Ok(2_000));
assert_eq!(transfer.convert(150), Ok(-500));
assert_eq!(transfer.convert(300), Ok(1_000));
}
#[test]
fn independent_boundary_behavior() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Error);
assert_eq!(transfer.convert(99), Ok(-1_000));
assert_eq!(
transfer.convert(401),
Err(TransferError::AboveDomain {
input: 401,
maximum: 400
})
);
}
#[test]
fn observation_guard_error_overrides_above_clamp() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp)
.with_observation_guard(65_535, ObservationGuardBehavior::Error);
assert_eq!(
transfer.observation_guard(),
Some(ObservationGuard {
code: 65_535,
behavior: ObservationGuardBehavior::Error,
})
);
assert_eq!(
transfer.convert(65_535),
Err(TransferError::RejectedObservation { input: 65_535 })
);
assert_eq!(transfer.convert(401), Ok(2_000));
assert_eq!(transfer.convert(400), Ok(2_000));
assert_eq!(transfer.convert(200), Ok(0));
}
#[test]
fn observation_guard_clamp_overrides_above_error() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Error)
.with_observation_guard(65_535, ObservationGuardBehavior::Clamp);
assert_eq!(transfer.convert(65_535), Ok(2_000));
assert_eq!(
transfer.convert(401),
Err(TransferError::AboveDomain {
input: 401,
maximum: 400
})
);
}
#[test]
fn observation_guard_absent_leaves_above_policy() {
let clamped =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
assert_eq!(clamped.observation_guard(), None);
assert_eq!(clamped.convert(65_535), Ok(2_000));
assert_eq!(clamped.convert(401), Ok(2_000));
let errored =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert_eq!(
errored.convert(65_535),
Err(TransferError::AboveDomain {
input: 65_535,
maximum: 400
})
);
assert_eq!(
errored.convert(401),
Err(TransferError::AboveDomain {
input: 401,
maximum: 400
})
);
}
#[test]
fn observation_guard_does_not_change_inverse() {
let error_above =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_observation_guard(65_535, ObservationGuardBehavior::Error);
assert_eq!(error_above.invert_physical(2_000), Ok(400));
assert_eq!(
error_above.invert_physical(2_001),
Err(InverseTransferError::AboveRange {
physical: 2_001,
maximum: 2_000
})
);
let clamp_above =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp)
.with_observation_guard(65_535, ObservationGuardBehavior::Error);
assert_eq!(clamp_above.invert_physical(3_000), Ok(400));
}
#[test]
fn observation_guard_rejects_code_inside_or_at_domain() {
assert!(
std::panic::catch_unwind(|| {
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_observation_guard(400, ObservationGuardBehavior::Error);
})
.is_err()
);
assert!(
std::panic::catch_unwind(|| {
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_observation_guard(200, ObservationGuardBehavior::Clamp);
})
.is_err()
);
}
#[test]
fn decreasing_signed_transfer() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing);
assert_eq!(transfer.convert(150), Ok(1_000));
assert_eq!(transfer.convert(300), Ok(-500));
}
#[test]
fn signed_rounding_ties_away_from_zero() {
assert_eq!(interpolate_segment(1, 0, 0, 2, 1), Ok(1));
assert_eq!(interpolate_segment(1, 0, 0, 2, -1), Ok(-1));
assert_eq!(interpolate_segment(1, 0, -10, 2, -9), Ok(-10));
assert_eq!(interpolate_segment(1, 0, 10, 2, 9), Ok(10));
assert_eq!(interpolate_segment(1, 0, 10, 3, 11), Ok(10));
assert_eq!(interpolate_segment(2, 0, 10, 3, 11), Ok(11));
assert_eq!(interpolate_segment(1, 0, -10, 3, -11), Ok(-10));
assert_eq!(interpolate_segment(2, 0, -10, 3, -11), Ok(-11));
}
#[test]
fn full_integer_ranges_are_safe() {
assert_eq!(
interpolate_segment(0, 0, i32::MIN, u16::MAX, i32::MAX),
Ok(i32::MIN)
);
assert_eq!(
interpolate_segment(u16::MAX, 0, i32::MIN, u16::MAX, i32::MAX),
Ok(i32::MAX)
);
assert_eq!(
interpolate_segment(0, 0, i32::MAX, u16::MAX, i32::MIN),
Ok(i32::MAX)
);
assert_eq!(
interpolate_segment(u16::MAX, 0, i32::MAX, u16::MAX, i32::MIN),
Ok(i32::MIN)
);
}
#[test]
fn exhaustive_full_span_is_bounded_and_monotonic() {
let mut previous_increasing = i32::MIN;
let mut previous_decreasing = i32::MAX;
for input in 0..=u16::MAX {
let increasing = interpolate_segment(input, 0, i32::MIN, u16::MAX, i32::MAX).unwrap();
let decreasing = interpolate_segment(input, 0, i32::MAX, u16::MAX, i32::MIN).unwrap();
assert!(increasing >= previous_increasing);
assert!(decreasing <= previous_decreasing);
previous_increasing = increasing;
previous_decreasing = decreasing;
}
assert_eq!(previous_increasing, i32::MAX);
assert_eq!(previous_decreasing, i32::MIN);
}
#[test]
fn constructor_rejects_invalid_tables() {
static DUPLICATE_INPUTS: [u16; 2] = [10, 10];
static DESCENDING_OUTPUTS: [i32; 2] = [10, 0];
assert!(
std::panic::catch_unwind(|| {
PiecewiseLinearTransfer::new(
&DUPLICATE_INPUTS,
&DESCENDING_OUTPUTS,
MonotonicDirection::Decreasing,
)
})
.is_err()
);
assert!(
std::panic::catch_unwind(|| {
PiecewiseLinearTransfer::new(
&[10, 20],
&DESCENDING_OUTPUTS,
MonotonicDirection::Increasing,
)
})
.is_err()
);
}
#[test]
fn standalone_interpolation_validates_arguments() {
assert_eq!(
interpolate_segment(10, 10, 0, 10, 1),
Err(InterpolationError::InvalidSpan)
);
assert_eq!(
interpolate_segment(9, 10, 0, 20, 1),
Err(InterpolationError::OutsideSegment {
input: 9,
minimum: 10,
maximum: 20
})
);
}
#[test]
fn standalone_inversion_validates_arguments() {
assert_eq!(
invert_segment(0, 10, 0, 10, 1),
Err(InterpolationError::InvalidSpan)
);
assert_eq!(
invert_segment(5, 10, 7, 20, 7),
Err(InterpolationError::FlatSegment)
);
assert_eq!(
invert_segment(21, 10, 0, 20, 20),
Err(InterpolationError::OutsidePhysicalSpan {
physical: 21,
minimum: 0,
maximum: 20
})
);
assert_eq!(
invert_segment(25, 10, 20, 20, 0),
Err(InterpolationError::OutsidePhysicalSpan {
physical: 25,
minimum: 0,
maximum: 20
})
);
}
#[test]
fn affine_identity_and_factory_scale() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let identity = AffineCalibration::new(base, 1, 0, 1).unwrap();
assert_eq!(identity.convert(150), Ok(-500));
assert_eq!(identity.gain(), 1);
assert_eq!(identity.offset(), 0);
assert_eq!(identity.scale(), 1);
assert_eq!(identity.inner().convert(150), Ok(-500));
let cal = AffineCalibration::new(base, 1005, -120, 1000).unwrap();
assert_eq!(cal.convert(150), Ok(-503));
assert_eq!(cal.convert(200), Ok(0));
assert_eq!(cal.convert(400), Ok(2_010));
}
#[test]
fn affine_rounding_ties_away_and_negative_scale() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let half_up = AffineCalibration::new(base, 1, 0, 2).unwrap();
assert_eq!(half_up.convert(400), Ok(1_000));
assert_eq!(half_up.convert(200), Ok(0));
let scalar = AffineTransform::new(1, 0, 2).unwrap();
assert_eq!(scalar.apply(1), Ok(1));
assert_eq!(scalar.apply(-1), Ok(-1));
assert_eq!(scalar.apply(1), half_up.transform().apply(1));
assert_eq!(AffineTransform::new(1, 0, -2).unwrap().apply(1), Ok(-1));
assert_eq!(AffineTransform::new(1, 0, -2).unwrap().apply(-1), Ok(1));
assert_eq!(scalar.apply(3), Ok(2));
assert_eq!(scalar.apply(-3), Ok(-2));
}
#[test]
fn affine_propagates_domain_errors_and_rejects_overflow() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 1, 0, 1).unwrap();
assert_eq!(
cal.convert(99),
Err(TransferError::BelowDomain {
input: 99,
minimum: 100
})
);
let guarded =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_observation_guard(65_535, ObservationGuardBehavior::Error);
let guarded_cal = AffineCalibration::new(guarded, 1, 0, 1).unwrap();
assert_eq!(
guarded_cal.convert(65_535),
Err(TransferError::RejectedObservation { input: 65_535 })
);
let overflow = AffineCalibration::new(base, i32::MAX, 0, 1).unwrap();
assert_eq!(overflow.convert(400), Err(TransferError::Overflow));
assert_eq!(
AffineTransform::new(2, 0, 1).unwrap().apply(i32::MAX),
Err(AffineOverflow::Overflow)
);
assert_eq!(
overflow.transform().apply(2_000),
Err(AffineOverflow::Overflow)
);
}
#[test]
fn standalone_inversion_matches_the_table_path() {
for physical in -1_000..=0 {
assert_eq!(
invert_segment(physical, 100, -1_000, 200, 0),
Ok(invert_valid_segment(physical, 100, -1_000, 200, 0)),
"physical {physical}"
);
}
assert_eq!(invert_segment(-500, 100, -1_000, 200, 0), Ok(150));
assert_eq!(invert_segment(1_000, 200, 0, 400, 2_000), Ok(300));
}
#[test]
fn invert_exact_knots_and_midpoints() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert_eq!(transfer.invert_physical(-1_000), Ok(100));
assert_eq!(transfer.invert_physical(0), Ok(200));
assert_eq!(transfer.invert_physical(2_000), Ok(400));
assert_eq!(transfer.invert_physical(-500), Ok(150));
assert_eq!(transfer.invert_physical(1_000), Ok(300));
}
#[test]
fn invert_decreasing_maps_by_physical_range() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing)
.with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
assert_eq!(transfer.invert_physical(1_000), Ok(150));
assert_eq!(transfer.invert_physical(-500), Ok(300));
assert_eq!(transfer.invert_physical(-2_000), Ok(400));
assert_eq!(transfer.invert_physical(3_000), Ok(100));
}
#[test]
fn boundary_policy_agrees_between_directions_on_a_decreasing_table() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing)
.with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp);
assert_eq!(transfer.convert(500), Ok(-1_000));
assert_eq!(transfer.invert_physical(-2_000), Ok(400));
assert_eq!(
transfer.convert(99),
Err(TransferError::BelowDomain {
input: 99,
minimum: 100
})
);
assert_eq!(
transfer.invert_physical(3_000),
Err(InverseTransferError::AboveRange {
physical: 3_000,
maximum: 2_000
})
);
assert_eq!(
transfer.range_behaviors(),
(BoundaryBehavior::Clamp, BoundaryBehavior::Error)
);
}
#[test]
fn boundary_policy_is_unswapped_on_an_increasing_table() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp);
assert_eq!(
transfer.range_behaviors(),
(BoundaryBehavior::Error, BoundaryBehavior::Clamp)
);
assert_eq!(transfer.convert(500), Ok(2_000));
assert_eq!(transfer.invert_physical(3_000), Ok(400));
assert_eq!(
transfer.invert_physical(-2_000),
Err(InverseTransferError::BelowRange {
physical: -2_000,
minimum: -1_000
})
);
}
#[test]
fn invert_range_errors_use_physical_bounds() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert_eq!(
transfer.invert(-1_001),
Err(InverseTransferError::BelowRange {
physical: -1_001,
minimum: -1_000
})
);
assert_eq!(
transfer.invert(2_001),
Err(InverseTransferError::AboveRange {
physical: 2_001,
maximum: 2_000
})
);
}
#[test]
fn flat_resolution_policies() {
let base = PiecewiseLinearTransfer::new(
&FLAT_INPUTS,
&FLAT_OUTPUTS,
MonotonicDirection::Increasing,
);
assert_eq!(
base.with_flat_resolution(FlatResolution::PreferLowInput)
.invert(10),
Ok(10)
);
assert_eq!(
base.with_flat_resolution(FlatResolution::PreferHighInput)
.invert(10),
Ok(20)
);
assert_eq!(
base.with_flat_resolution(FlatResolution::Midpoint)
.invert(10),
Ok(15)
);
assert_eq!(
base.with_flat_resolution(FlatResolution::Error).invert(10),
Err(InverseTransferError::AmbiguousFlat {
physical: 10,
low: 10,
high: 20
})
);
assert_eq!(
base.with_flat_resolution(FlatResolution::Error).invert(0),
Ok(0)
);
assert_eq!(base.invert(5), Ok(5));
}
#[test]
fn invert_segment_rounding_ties_away() {
assert_eq!(invert_valid_segment(1, 0, 0, 2, 2), 1);
assert_eq!(invert_valid_segment(-1, 0, 0, 2, -2), 1);
assert_eq!(invert_valid_segment(1, 0, 0, 4, 2), 2);
assert_eq!(invert_valid_segment(-1, 0, 0, 4, -2), 2);
}
#[test]
fn round_trip_invert_convert_on_non_flat_table() {
let transfer =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
for input in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
let physical = transfer.convert(input).unwrap();
let recovered = transfer.invert(physical).unwrap();
let distance = i32::from(recovered).abs_diff(i32::from(input));
assert!(
distance <= 1,
"input {input}: invert(convert) -> {recovered} (Δ={distance})"
);
}
}
#[test]
fn div_nearest_ties_away_matches_forward_policy() {
assert_eq!(div_nearest_ties_away(1, 2), 1);
assert_eq!(div_nearest_ties_away(-1, 2), -1);
assert_eq!(div_nearest_ties_away(1, -2), -1);
assert_eq!(div_nearest_ties_away(-1, -2), 1);
assert_eq!(div_nearest_ties_away(3, 2), 2);
assert_eq!(div_nearest_ties_away(-3, 2), -2);
}
#[test]
fn affine_constructor_returns_error_for_zero_scale() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert!(matches!(
AffineCalibration::new(base, 1, 0, 0),
Err(AffineCalibrationError::ZeroScale)
));
}
#[test]
fn affine_nesting_composes() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let inner = AffineCalibration::new(base, 2, 10, 1).unwrap();
let outer = AffineCalibration::new(inner, 1, -10, 2).unwrap();
assert_eq!(outer.convert(200), Ok(0));
assert_eq!(outer.convert(400), Ok(2_000));
}
#[test]
fn affine_constructor_returns_error_for_zero_gain() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
assert!(matches!(
AffineCalibration::new(base, 0, 5, 1),
Err(AffineCalibrationError::ZeroGain)
));
}
#[test]
fn calibrated_inverse_round_trips_through_the_table() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 1_005, -120, 1_000).unwrap();
for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
let calibrated = cal.convert(code).unwrap();
let recovered = cal.invert(calibrated).unwrap();
assert!(
recovered.abs_diff(code) <= 1,
"code {code}: convert -> {calibrated} -> invert -> {recovered}"
);
}
}
#[test]
fn calibrated_inverse_survives_compressing_calibration_at_endpoints() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 2, 0, 3).unwrap();
let low = cal.convert(100).unwrap();
assert_eq!(low, -667);
assert_eq!(cal.invert(low), Ok(100));
let high = cal.convert(400).unwrap();
assert_eq!(high, 1_333);
assert_eq!(cal.invert(high), Ok(400));
for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
let calibrated = cal.convert(code).unwrap();
let recovered = cal.invert(calibrated).unwrap();
assert!(
recovered.abs_diff(code) <= 1,
"code {code}: convert -> {calibrated} -> invert -> {recovered}"
);
}
assert_eq!(
cal.invert(-668),
Err(InverseTransferError::BelowRange {
physical: -668,
minimum: -667
})
);
assert_eq!(
cal.invert(1_334),
Err(InverseTransferError::AboveRange {
physical: 1_334,
maximum: 1_333
})
);
}
#[test]
fn calibrated_inverse_recovers_i32_endpoints_after_scalar_inverse_overflow() {
let tables = [
(&FULL_INCREASING, MonotonicDirection::Increasing),
(&FULL_DECREASING, MonotonicDirection::Decreasing),
];
for (outputs, direction) in tables {
let base = PiecewiseLinearTransfer::new(&FULL_INPUTS, outputs, direction);
for gain in [2, -2] {
for scale in [3, -3] {
for offset in [-1, 0, 1] {
let cal = AffineCalibration::new(base, gain, offset, scale).unwrap();
for code in [0, u16::MAX] {
let calibrated = cal.convert(code).unwrap();
assert_eq!(
cal.invert(calibrated),
Ok(code),
"code={code} converted={calibrated} gain={gain} offset={offset} scale={scale} direction={direction:?}"
);
}
}
}
}
}
let scalar = AffineTransform::new(2, 0, 3).unwrap();
let converted = scalar.apply(i32::MAX).unwrap();
assert_eq!(scalar.unapply(converted), Err(AffineOverflow::Overflow));
let scalar = AffineTransform::new(2, -1, 3).unwrap();
let converted = scalar.apply(i32::MIN).unwrap();
assert_eq!(scalar.unapply(converted), Err(AffineOverflow::Overflow));
}
#[test]
fn compressing_calibration_still_flips_orientation_on_range_errors() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let flipped = AffineCalibration::new(base, -2, 0, 3).unwrap();
assert!(!flipped.preserves_orientation());
let low_obs = flipped.convert(100).unwrap();
let high_obs = flipped.convert(400).unwrap();
assert_eq!(flipped.invert(low_obs), Ok(100));
assert_eq!(flipped.invert(high_obs), Ok(400));
assert_eq!(
flipped.invert(low_obs + 1),
Err(InverseTransferError::AboveRange {
physical: low_obs + 1,
maximum: low_obs
})
);
assert_eq!(
flipped.invert(high_obs - 1),
Err(InverseTransferError::BelowRange {
physical: high_obs - 1,
minimum: high_obs
})
);
}
#[test]
fn calibrated_inverse_undoes_the_affine_before_the_table() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let identity = AffineCalibration::new(base, 1, 0, 1).unwrap();
assert_eq!(identity.invert(-500), Ok(150));
assert_eq!(identity.invert(0), Ok(200));
let scaled = AffineCalibration::new(base, 10, 0, 1).unwrap();
assert_eq!(scaled.invert(-5_000), Ok(150));
assert_eq!(scaled.convert(150), Ok(-5_000));
}
#[test]
fn calibrated_inverse_reports_bounds_in_calibrated_units() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 10, 0, 1).unwrap();
assert_eq!(cal.invert(-10_001), Ok(100));
assert_eq!(cal.invert(20_001), Ok(400));
assert_eq!(
cal.invert(-10_010),
Err(InverseTransferError::BelowRange {
physical: -10_010,
minimum: -10_000
})
);
assert_eq!(
cal.invert(20_010),
Err(InverseTransferError::AboveRange {
physical: 20_010,
maximum: 20_000
})
);
}
#[test]
fn calibrated_inverse_flips_variants_when_orientation_reverses() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let flipped = AffineCalibration::new(base, -1, 0, 1).unwrap();
assert!(!flipped.preserves_orientation());
assert_eq!(flipped.convert(100), Ok(1_000));
assert_eq!(flipped.convert(400), Ok(-2_000));
assert_eq!(flipped.invert(1_000), Ok(100));
assert_eq!(flipped.invert(-2_000), Ok(400));
assert_eq!(
flipped.invert(1_001),
Err(InverseTransferError::AboveRange {
physical: 1_001,
maximum: 1_000
})
);
assert_eq!(
flipped.invert(-2_001),
Err(InverseTransferError::BelowRange {
physical: -2_001,
minimum: -2_000
})
);
}
#[test]
fn calibrated_inverse_honors_clamp_and_flat_policy() {
let clamped =
PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing)
.with_boundaries(BoundaryBehavior::Clamp, BoundaryBehavior::Clamp);
let cal = AffineCalibration::new(clamped, 10, 0, 1).unwrap();
assert_eq!(cal.invert(-99_999), Ok(100));
assert_eq!(cal.invert(99_999), Ok(400));
let flat = PiecewiseLinearTransfer::new(
&FLAT_INPUTS,
&FLAT_OUTPUTS,
MonotonicDirection::Increasing,
)
.with_flat_resolution(FlatResolution::Error);
let cal = AffineCalibration::new(flat, 2, 0, 1).unwrap();
assert_eq!(
cal.invert(20),
Err(InverseTransferError::AmbiguousFlat {
physical: 20,
low: 10,
high: 20
})
);
}
#[test]
fn calibrated_inverse_rejects_unrepresentable_input() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 1, 0, i32::MAX).unwrap();
assert_eq!(cal.invert(i32::MAX), Err(InverseTransferError::Overflow));
}
#[test]
fn nested_calibration_inverts_through_every_layer() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let inner = AffineCalibration::new(base, 2, 10, 1).unwrap();
let outer = AffineCalibration::new(inner, 1, -10, 2).unwrap();
assert_eq!(outer.convert(200), Ok(0));
assert_eq!(outer.invert(0), Ok(200));
assert_eq!(outer.convert(400), Ok(2_000));
assert_eq!(outer.invert(2_000), Ok(400));
}
#[test]
fn affine_calibration_convert_matches_direct_transform_apply() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cases = [
(1, 0, 1),
(1_005, -120, 1_000),
(2, 0, 3),
(-2, 0, 3),
(10, 0, 1),
(-1, 0, 1),
];
for (gain, offset, scale) in cases {
let cal = AffineCalibration::new(base, gain, offset, scale).unwrap();
assert_eq!(cal.transform().gain(), cal.gain());
assert_eq!(cal.transform().offset(), cal.offset());
assert_eq!(cal.transform().scale(), cal.scale());
for code in INPUTS[0]..=INPUTS[INPUTS.len() - 1] {
let from_wrapper = cal.convert(code).unwrap();
let inner = cal.inner().convert(code).unwrap();
let from_scalar = cal.transform().apply(inner).unwrap();
assert_eq!(
from_wrapper, from_scalar,
"code {code}: convert={from_wrapper} apply={from_scalar} (gain={gain} offset={offset} scale={scale})"
);
}
}
}
#[test]
fn affine_calibration_invert_matches_unapply_then_inner() {
let base = PiecewiseLinearTransfer::new(&INPUTS, &OUTPUTS, MonotonicDirection::Increasing);
let cal = AffineCalibration::new(base, 10, 0, 1).unwrap();
let calibrated = cal.convert(150).unwrap();
let uncalibrated = cal.transform().unapply(calibrated).unwrap();
assert_eq!(cal.inner().invert(uncalibrated), Ok(150));
assert_eq!(cal.invert(calibrated), Ok(150));
let compressed = AffineCalibration::new(base, 2, 0, 3).unwrap();
let low = compressed.convert(100).unwrap();
assert_eq!(low, -667);
let undone = compressed.transform().unapply(low).unwrap();
assert!(undone < -1_000, "unapply overshoots inner min: {undone}");
assert_eq!(
compressed.inner().invert(undone),
Err(InverseTransferError::BelowRange {
physical: undone,
minimum: -1_000,
})
);
assert_eq!(compressed.invert(low), Ok(100));
let flipped = AffineCalibration::new(base, -1, 0, 1).unwrap();
let high_calibrated = flipped.convert(100).unwrap();
assert_eq!(high_calibrated, 1_000);
assert_eq!(flipped.transform().unapply(1_001).unwrap(), -1_001);
assert_eq!(
flipped.invert(1_001),
Err(InverseTransferError::AboveRange {
physical: 1_001,
maximum: 1_000
})
);
}
}