1#[derive(Clone, Copy, Debug)]
7pub enum ParamRange {
8 Linear {
9 min: f64,
10 max: f64,
11 },
12 Logarithmic {
13 min: f64,
14 max: f64,
15 },
16 Skewed {
21 min: f64,
22 max: f64,
23 factor: f64,
24 },
25 SymmetricalSkewed {
31 min: f64,
32 max: f64,
33 factor: f64,
34 center: f64,
35 },
36 Discrete {
37 min: i64,
38 max: i64,
39 },
40 Enum {
41 count: usize,
42 },
43 Reversed(&'static ParamRange),
47}
48
49impl ParamRange {
50 #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
64 #[must_use]
65 pub fn normalize(&self, plain: f64) -> f64 {
66 if plain.is_nan() {
70 return 0.0;
71 }
72 match self {
73 Self::Linear { min, max } => {
74 if max == min {
75 return 0.0;
76 }
77 ((plain - min) / (max - min)).clamp(0.0, 1.0)
78 }
79 Self::Logarithmic { min, max } => {
80 if *min <= 0.0 || *max <= 0.0 || min == max {
81 return 0.0;
82 }
83 if plain <= *min {
89 return 0.0;
90 }
91 if plain >= *max {
92 return 1.0;
93 }
94 let min_log = min.ln();
95 let max_log = max.ln();
96 ((plain.ln() - min_log) / (max_log - min_log)).clamp(0.0, 1.0)
97 }
98 Self::Skewed { min, max, factor } => {
99 if max == min {
100 return 0.0;
101 }
102 let t = ((plain - min) / (max - min)).clamp(0.0, 1.0);
103 t.powf(*factor)
104 }
105 Self::SymmetricalSkewed {
106 min,
107 max,
108 factor,
109 center,
110 } => {
111 if max == min {
112 return 0.0;
113 }
114 let unscaled = ((plain - min) / (max - min)).clamp(0.0, 1.0);
115 let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
116 if center_prop <= 0.0 || center_prop >= 1.0 {
120 return unscaled;
121 }
122 if unscaled > center_prop {
123 let scaled = (unscaled - center_prop) / (1.0 - center_prop);
124 (scaled.powf(*factor) / 2.0) + 0.5
125 } else {
126 let scaled = (center_prop - unscaled) / center_prop;
127 (1.0 - scaled.powf(*factor)) / 2.0
128 }
129 }
130 Self::Reversed(inner) => 1.0 - inner.normalize(plain),
131 Self::Discrete { min, max } => {
132 if max == min {
133 return 0.0;
134 }
135 ((plain - *min as f64) / (*max as f64 - *min as f64)).clamp(0.0, 1.0)
136 }
137 Self::Enum { count } => {
138 if *count <= 1 {
139 return 0.0;
140 }
141 (plain / (*count as f64 - 1.0)).clamp(0.0, 1.0)
142 }
143 }
144 }
145
146 #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
156 #[must_use]
157 pub fn denormalize(&self, normalized: f64) -> f64 {
158 let n = if normalized.is_nan() {
165 0.0
166 } else {
167 normalized.clamp(0.0, 1.0)
168 };
169 match self {
170 Self::Linear { min, max } => min + n * (max - min),
171 Self::Logarithmic { min, max } => {
172 if *min <= 0.0 || *max <= 0.0 || min == max {
176 return *min;
177 }
178 let min_log = min.ln();
179 let max_log = max.ln();
180 (min_log + n * (max_log - min_log)).exp()
181 }
182 Self::Skewed { min, max, factor } => {
183 if max == min {
184 return *min;
185 }
186 min + n.powf(factor.recip()) * (max - min)
187 }
188 Self::SymmetricalSkewed {
189 min,
190 max,
191 factor,
192 center,
193 } => {
194 if max == min {
195 return *min;
196 }
197 let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
198 if center_prop <= 0.0 || center_prop >= 1.0 {
199 return min + n * (max - min);
200 }
201 let skewed_prop = if n > 0.5 {
202 let scaled = (n - 0.5) * 2.0;
203 (scaled.powf(factor.recip()) * (1.0 - center_prop)) + center_prop
204 } else {
205 let inverse = (1.0 - n * 2.0).powf(factor.recip());
206 (1.0 - inverse) * center_prop
207 };
208 min + skewed_prop * (max - min)
209 }
210 Self::Reversed(inner) => inner.denormalize(1.0 - n),
211 Self::Discrete { min, max } => {
212 ((*min as f64) + n * (*max as f64 - *min as f64)).round()
213 }
214 Self::Enum { count } => {
215 if *count <= 1 {
216 return 0.0;
217 }
218 (n * (*count as f64 - 1.0)).round()
219 }
220 }
221 }
222
223 #[allow(clippy::cast_precision_loss)]
227 #[must_use]
228 pub fn min(&self) -> f64 {
229 match self {
230 Self::Linear { min, .. }
231 | Self::Logarithmic { min, .. }
232 | Self::Skewed { min, .. }
233 | Self::SymmetricalSkewed { min, .. } => *min,
234 Self::Discrete { min, .. } => *min as f64,
235 Self::Enum { .. } => 0.0,
236 Self::Reversed(inner) => inner.min(),
237 }
238 }
239
240 #[allow(clippy::cast_precision_loss)]
244 #[must_use]
245 pub fn max(&self) -> f64 {
246 match self {
247 Self::Linear { max, .. }
248 | Self::Logarithmic { max, .. }
249 | Self::Skewed { max, .. }
250 | Self::SymmetricalSkewed { max, .. } => *max,
251 Self::Discrete { max, .. } => *max as f64,
252 Self::Enum { count } => (*count as f64 - 1.0).max(0.0),
253 Self::Reversed(inner) => inner.max(),
254 }
255 }
256
257 #[must_use]
269 pub fn step_count(&self) -> Option<std::num::NonZeroU32> {
270 let raw: u32 = match self {
271 Self::Linear { .. }
272 | Self::Logarithmic { .. }
273 | Self::Skewed { .. }
274 | Self::SymmetricalSkewed { .. } => 0,
275 Self::Reversed(inner) => return inner.step_count(),
278 Self::Discrete { min, max } => {
283 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
285 let n = (max.saturating_sub(*min)).max(0).min(i64::from(u32::MAX)) as u32;
286 n
287 }
288 #[allow(clippy::cast_possible_truncation)]
291 Self::Enum { count } => (*count as u32).saturating_sub(1),
292 };
293 std::num::NonZeroU32::new(raw)
294 }
295
296 #[must_use]
301 pub fn step_count_usize(&self) -> usize {
302 self.step_count().map_or(1, |n| n.get() as usize)
303 }
304
305 #[must_use]
311 pub fn base(&self) -> &Self {
312 match self {
313 Self::Reversed(inner) => inner.base(),
314 other => other,
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 #![allow(
326 clippy::float_cmp,
327 clippy::cast_possible_truncation,
328 clippy::cast_sign_loss,
329 clippy::cast_precision_loss
330 )]
331
332 use super::*;
333
334 #[test]
335 fn linear_round_trip() {
336 let range = ParamRange::Linear {
337 min: -60.0,
338 max: 24.0,
339 };
340 for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
341 let norm = range.normalize(plain);
342 let back = range.denormalize(norm);
343 assert!(
344 (back - plain).abs() < 1e-10,
345 "plain={plain}, norm={norm}, back={back}"
346 );
347 }
348 }
349
350 #[test]
351 fn log_round_trip() {
352 let range = ParamRange::Logarithmic {
353 min: 20.0,
354 max: 20000.0,
355 };
356 for plain in [20.0, 100.0, 1000.0, 10000.0, 20000.0] {
357 let norm = range.normalize(plain);
358 let back = range.denormalize(norm);
359 assert!(
360 (back - plain).abs() < 0.01,
361 "plain={plain}, norm={norm}, back={back}"
362 );
363 }
364 }
365
366 #[test]
367 fn enum_round_trip() {
368 let range = ParamRange::Enum { count: 4 };
369 for idx in 0..4 {
370 let norm = range.normalize(idx as f64);
371 let back = range.denormalize(norm);
372 assert_eq!(back as usize, idx);
373 }
374 }
375
376 #[test]
377 fn skewed_round_trip() {
378 let range = ParamRange::Skewed {
379 min: 0.0,
380 max: 100.0,
381 factor: 0.5,
382 };
383 for plain in [0.0, 10.0, 50.0, 90.0, 100.0] {
384 let back = range.denormalize(range.normalize(plain));
385 assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
386 }
387 }
388
389 #[test]
390 fn skewed_factor_one_matches_linear() {
391 let skewed = ParamRange::Skewed {
392 min: -60.0,
393 max: 24.0,
394 factor: 1.0,
395 };
396 let linear = ParamRange::Linear {
397 min: -60.0,
398 max: 24.0,
399 };
400 for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
401 assert!((skewed.normalize(plain) - linear.normalize(plain)).abs() < 1e-12);
402 }
403 }
404
405 #[test]
406 fn skewed_low_factor_gives_low_end_more_knob() {
407 let range = ParamRange::Skewed {
410 min: 0.0,
411 max: 100.0,
412 factor: 0.5,
413 };
414 assert!(range.denormalize(0.5) < 50.0);
415 }
416
417 #[test]
418 fn symmetrical_skewed_center_at_half() {
419 let range = ParamRange::SymmetricalSkewed {
422 min: -24.0,
423 max: 6.0,
424 factor: 0.5,
425 center: 0.0,
426 };
427 assert!((range.normalize(0.0) - 0.5).abs() < 1e-12);
428 assert!((range.denormalize(0.5) - 0.0).abs() < 1e-9);
429 }
430
431 #[test]
432 fn symmetrical_skewed_round_trip() {
433 let range = ParamRange::SymmetricalSkewed {
434 min: -1.0,
435 max: 1.0,
436 factor: 2.0,
437 center: 0.0,
438 };
439 for plain in [-1.0, -0.5, -0.1, 0.0, 0.1, 0.5, 1.0] {
440 let back = range.denormalize(range.normalize(plain));
441 assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
442 }
443 }
444
445 #[test]
446 fn symmetrical_skewed_is_symmetric_about_a_centered_center() {
447 let range = ParamRange::SymmetricalSkewed {
450 min: -1.0,
451 max: 1.0,
452 factor: 0.6,
453 center: 0.0,
454 };
455 for d in [0.25, 0.5, 0.75] {
456 let above = range.normalize(d) - 0.5;
457 let below = 0.5 - range.normalize(-d);
458 assert!((above - below).abs() < 1e-12, "asymmetric at d={d}");
459 }
460 }
461
462 #[test]
463 fn reversed_flips_the_axis() {
464 static INNER: ParamRange = ParamRange::Linear {
465 min: 0.0,
466 max: 100.0,
467 };
468 let range = ParamRange::Reversed(&INNER);
469 assert!((range.normalize(0.0) - 1.0).abs() < 1e-12, "min -> top");
470 assert!((range.normalize(100.0)).abs() < 1e-12, "max -> bottom");
471 assert!((range.denormalize(0.0) - 100.0).abs() < 1e-9);
472 assert!((range.denormalize(1.0)).abs() < 1e-9);
473 assert_eq!(range.min(), 0.0);
475 assert_eq!(range.max(), 100.0);
476 assert!(range.step_count().is_none());
477 }
478
479 #[test]
480 fn base_peels_reversed_so_shape_survives() {
481 static ENUM: ParamRange = ParamRange::Enum { count: 4 };
482 static ONCE: ParamRange = ParamRange::Reversed(&ENUM);
483
484 let reversed = ParamRange::Reversed(&ENUM);
487 assert!(matches!(reversed.base(), ParamRange::Enum { count: 4 }));
488
489 let twice = ParamRange::Reversed(&ONCE);
491 assert!(matches!(twice.base(), ParamRange::Enum { count: 4 }));
492
493 let linear = ParamRange::Linear { min: 0.0, max: 1.0 };
495 assert!(matches!(linear.base(), ParamRange::Linear { .. }));
496 }
497
498 #[test]
499 fn reversed_round_trip_over_log() {
500 static INNER: ParamRange = ParamRange::Logarithmic {
501 min: 20.0,
502 max: 20000.0,
503 };
504 let range = ParamRange::Reversed(&INNER);
505 for plain in [20.0, 200.0, 2000.0, 20000.0] {
506 let back = range.denormalize(range.normalize(plain));
507 assert!((back - plain).abs() < 0.01, "plain={plain}, back={back}");
508 }
509 }
510
511 #[test]
512 fn reversed_discrete_keeps_step_count() {
513 static INNER: ParamRange = ParamRange::Discrete { min: 0, max: 3 };
514 let range = ParamRange::Reversed(&INNER);
515 assert_eq!(range.step_count_usize(), 3);
516 }
517
518 #[test]
525 fn degenerate_bounds_round_trip_stable() {
526 let cases = [
527 ParamRange::Linear { min: 5.0, max: 5.0 },
528 ParamRange::Logarithmic {
529 min: 100.0,
530 max: 100.0,
531 },
532 ParamRange::Logarithmic {
533 min: -1.0,
534 max: 10.0,
535 },
536 ParamRange::Logarithmic { min: 1.0, max: 0.0 },
537 ParamRange::Discrete { min: 7, max: 7 },
538 ParamRange::Enum { count: 0 },
539 ParamRange::Enum { count: 1 },
540 ];
541 for range in cases {
542 let bottom = range.min();
543 assert_eq!(range.normalize(bottom), 0.0, "normalize(min) for {range:?}");
544 assert_eq!(
545 range.normalize(42.0),
546 0.0,
547 "normalize(arbitrary) for {range:?}"
548 );
549 assert_eq!(
550 range.denormalize(0.0),
551 bottom,
552 "denormalize(0.0) for {range:?}"
553 );
554 assert_eq!(
555 range.denormalize(0.5),
556 bottom,
557 "denormalize(mid) for {range:?}"
558 );
559 let once = range.denormalize(range.normalize(42.0));
561 let twice = range.denormalize(range.normalize(once));
562 assert_eq!(once, twice, "round-trip not stable for {range:?}");
563 }
564 }
565
566 #[test]
571 fn logarithmic_normalize_never_nan() {
572 let range = ParamRange::Logarithmic {
573 min: 20.0,
574 max: 20000.0,
575 };
576 for plain in [-1.0, 0.0, 0.5, 19.99, f64::NEG_INFINITY] {
577 let n = range.normalize(plain);
578 assert!(!n.is_nan(), "NaN from normalize({plain})");
579 assert_eq!(n, 0.0, "normalize({plain}) should clamp to 0.0");
580 }
581 for plain in [20000.0, 20001.0, 1e9, f64::INFINITY] {
582 let n = range.normalize(plain);
583 assert!(!n.is_nan(), "NaN from normalize({plain})");
584 assert_eq!(n, 1.0, "normalize({plain}) should clamp to 1.0");
585 }
586 }
587
588 #[test]
594 fn nan_input_collapses_to_low_end() {
595 let ranges = [
596 ParamRange::Linear {
597 min: -60.0,
598 max: 6.0,
599 },
600 ParamRange::Logarithmic {
601 min: 20.0,
602 max: 20000.0,
603 },
604 ParamRange::Discrete { min: 3, max: 9 },
605 ParamRange::Enum { count: 4 },
606 ParamRange::Skewed {
607 min: 0.0,
608 max: 100.0,
609 factor: 0.5,
610 },
611 ParamRange::Reversed(&ParamRange::Linear {
612 min: 0.0,
613 max: 10.0,
614 }),
615 ];
616 for range in ranges {
617 assert_eq!(
618 range.normalize(f64::NAN),
619 0.0,
620 "normalize(NaN) for {range:?}"
621 );
622 let d = range.denormalize(f64::NAN);
623 assert!(d.is_finite(), "denormalize(NaN) is finite for {range:?}");
624 let low = range.denormalize(0.0);
629 assert!(
630 (d - low).abs() <= 1e-9 * low.abs().max(1.0),
631 "denormalize(NaN) must equal the low end for {range:?}: {d} vs {low}",
632 );
633 }
634 }
635}