1use crate::affine::{AffineOverflow, AffineTransform, AffineTransformError};
11use crate::round::div_nearest_ties_away;
12
13pub trait TransferFunction {
15 type Input: Copy;
17 type Output: Copy;
19
20 fn convert(&self, input: Self::Input) -> Result<Self::Output, TransferError<Self::Input>>;
27}
28
29pub trait InverseTransferFunction {
34 type Physical: Copy;
36 type Observation: Copy;
38
39 fn invert(
49 &self,
50 physical: Self::Physical,
51 ) -> Result<Self::Observation, InverseTransferError<Self::Physical>>;
52}
53
54#[derive(Copy, Clone, Debug, Eq, PartialEq)]
56pub enum MonotonicDirection {
57 Increasing,
59 Decreasing,
61}
62
63#[derive(Copy, Clone, Debug, Eq, PartialEq)]
72pub enum BoundaryBehavior {
73 Error,
75 Clamp,
77}
78
79#[derive(Copy, Clone, Debug, Eq, PartialEq)]
85pub enum ObservationGuardBehavior {
86 Error,
88 Clamp,
90}
91
92#[derive(Copy, Clone, Debug, Eq, PartialEq)]
97pub struct ObservationGuard {
98 pub code: u16,
100 pub behavior: ObservationGuardBehavior,
102}
103
104#[derive(Copy, Clone, Debug, Eq, PartialEq)]
111pub struct ObservationGuardMetadata {
112 pub code: u16,
114 pub behavior: ObservationGuardBehavior,
116}
117
118#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
120pub enum FlatResolution {
121 #[default]
123 PreferLowInput,
124 PreferHighInput,
126 Midpoint,
128 Error,
130}
131
132#[derive(Copy, Clone, Debug, Eq, PartialEq)]
136pub enum TransferError<I> {
137 BelowDomain {
139 input: I,
141 minimum: I,
143 },
144 AboveDomain {
146 input: I,
148 maximum: I,
150 },
151 RejectedObservation {
157 input: I,
159 },
160 Overflow,
163}
164
165#[derive(Copy, Clone, Debug, Eq, PartialEq)]
167pub enum InverseTransferError<P> {
168 BelowRange {
170 physical: P,
172 minimum: P,
174 },
175 AboveRange {
177 physical: P,
179 maximum: P,
181 },
182 AmbiguousFlat {
184 physical: P,
186 low: u16,
188 high: u16,
190 },
191 Overflow,
194}
195
196#[derive(Copy, Clone, Debug, Eq, PartialEq)]
198pub enum InterpolationError {
199 InvalidSpan,
201 OutsideSegment {
203 input: u16,
205 minimum: u16,
207 maximum: u16,
209 },
210 FlatSegment,
212 OutsidePhysicalSpan {
214 physical: i32,
216 minimum: i32,
218 maximum: i32,
220 },
221}
222
223#[derive(Copy, Clone, Debug, Eq, PartialEq)]
229pub struct TransferMetadata {
230 pub input_unit: &'static str,
232 pub output_unit: &'static str,
234 pub output_scale: u32,
236 pub domain_min: u16,
238 pub domain_max: u16,
240 pub range_min: i32,
242 pub range_max: i32,
244 pub direction: MonotonicDirection,
246 pub knot_count: usize,
248 pub strictly_monotonic: bool,
250 pub flat_segment_count: usize,
252 pub requested_max_error: u32,
254 pub achieved_max_error: u32,
256 pub worst_case_input: u16,
258 pub achieved_max_inverse_code_error: u16,
269}
270
271#[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 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 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 pub const fn with_flat_resolution(mut self, policy: FlatResolution) -> Self {
352 self.flat_resolution = policy;
353 self
354 }
355
356 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 pub const fn observation_guard(&self) -> Option<ObservationGuard> {
383 self.observation_guard
384 }
385
386 pub const fn inputs(&self) -> &'static [u16; N] {
388 self.inputs
389 }
390
391 pub const fn outputs(&self) -> &'static [i32; N] {
393 self.outputs
394 }
395
396 pub const fn direction(&self) -> MonotonicDirection {
398 self.direction
399 }
400
401 pub const fn below_behavior(&self) -> BoundaryBehavior {
407 self.below
408 }
409
410 pub const fn above_behavior(&self) -> BoundaryBehavior {
416 self.above
417 }
418
419 pub const fn flat_resolution(&self) -> FlatResolution {
421 self.flat_resolution
422 }
423
424 pub const fn domain(&self) -> (u16, u16) {
426 (self.inputs[0], self.inputs[N - 1])
427 }
428
429 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 pub fn invert_physical(&self, physical: i32) -> Result<u16, InverseTransferError<i32>> {
444 InverseTransferFunction::invert(self, physical)
445 }
446
447 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 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 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
634pub 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
658pub 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#[derive(Copy, Clone, Debug)]
753pub struct AffineCalibration<T> {
754 inner: T,
755 transform: AffineTransform,
756}
757
758#[derive(Copy, Clone, Debug, Eq, PartialEq)]
760pub enum AffineCalibrationError {
761 ZeroScale,
763 ZeroGain,
765}
766
767impl<T> AffineCalibration<T> {
768 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 pub const fn inner(&self) -> &T {
791 &self.inner
792 }
793
794 pub const fn transform(&self) -> AffineTransform {
799 self.transform
800 }
801
802 pub const fn gain(&self) -> i32 {
804 self.transform.gain()
805 }
806
807 pub const fn offset(&self) -> i32 {
809 self.transform.offset()
810 }
811
812 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 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 const fn preserves_orientation(&self) -> bool {
875 (self.transform.gain() > 0) == (self.transform.scale() > 0)
876 }
877
878 fn recover_inverse_overflow(
889 &self,
890 physical: i32,
891 ) -> Result<T::Observation, InverseTransferError<i32>>
892 where
893 T: InverseTransferFunction<Physical = i32>,
894 {
895 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 return Err(InverseTransferError::Overflow);
909 };
910
911 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 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 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 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 assert_eq!(cal.convert(150), Ok(-503));
1288 assert_eq!(cal.convert(200), Ok(0));
1290 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 assert_eq!(half_up.convert(400), Ok(1_000));
1300 assert_eq!(half_up.convert(200), Ok(0));
1301
1302 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 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 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 assert_eq!(transfer.invert_physical(-2_000), Ok(400));
1381 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 let transfer =
1389 PiecewiseLinearTransfer::new(&INPUTS, &DECREASING, MonotonicDirection::Decreasing)
1390 .with_boundaries(BoundaryBehavior::Error, BoundaryBehavior::Clamp);
1391
1392 assert_eq!(transfer.convert(500), Ok(-1_000));
1395 assert_eq!(transfer.invert_physical(-2_000), Ok(400));
1396
1397 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 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 assert_eq!(invert_valid_segment(1, 0, 0, 2, 2), 1);
1505 assert_eq!(invert_valid_segment(-1, 0, 0, 2, -2), 1);
1506 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 assert_eq!(outer.convert(200), Ok(0));
1552 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 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 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 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 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 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 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 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 let cal = AffineCalibration::new(base, 10, 0, 1).unwrap();
1707
1708 assert_eq!(cal.invert(-10_001), Ok(100));
1712 assert_eq!(cal.invert(20_001), Ok(400));
1713
1714 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 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 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 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 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 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 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 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}