Group units, formatting, and statistics under quantity

This commit is contained in:
David Peter committed 2026-10-05 10:46:51 +02:00
1 parent bef66df7e4
commit aa14b8c0d9
20 files changed
+266 -265

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
use std::marker::PhantomData;
pub use uom::num_traits::Zero;
pub use uom::si::f64::{Information, Ratio, Time};
pub use uom::si::information::byte;
pub use uom::si::ratio::ratio;
pub use uom::si::time::{microsecond, nanosecond, second};
pub use units::TimeUnit;
pub mod statistics;
mod units;
pub const fn const_time_from_seconds(value: f64) -> Time {
// Quantity::new in uom is not yet const: https://docs.rs/uom/0.36.0/uom/si/struct.Quantity.html
Time {
dimension: PhantomData,
units: PhantomData,
value,
}
}
/// Format the given duration as a string. The output-unit can be enforced by setting `unit` to
/// `Some(target_unit)`. If `unit` is `None`, it will be determined automatically.
pub fn format_duration(duration: f64, unit: Option<TimeUnit>) -> String {
let (duration_fmt, _) = format_duration_unit(duration, unit);
duration_fmt
}
/// Like `format_duration`, but returns the target unit as well.
pub fn format_duration_unit(duration: f64, unit: Option<TimeUnit>) -> (String, TimeUnit) {
let (out_str, out_unit) = format_duration_value(duration, unit);
(format!("{} {}", out_str, out_unit.short_name()), out_unit)
}
/// Like `format_duration`, but returns the target unit as well.
pub fn format_duration_value(duration: f64, unit: Option<TimeUnit>) -> (String, TimeUnit) {
if (duration < 0.001 && unit.is_none()) || unit == Some(TimeUnit::MicroSecond) {
(
TimeUnit::MicroSecond.format(duration),
TimeUnit::MicroSecond,
)
} else if (duration < 1.0 && unit.is_none()) || unit == Some(TimeUnit::MilliSecond) {
(
TimeUnit::MilliSecond.format(duration),
TimeUnit::MilliSecond,
)
} else {
let unit = unit.unwrap_or(TimeUnit::Second);
(unit.format(duration), unit)
}
}
#[cfg(test)]
use uom::si::{information::kibibyte, time::millisecond};
#[test]
fn test_time() {
let time = Time::new::<millisecond>(123.4);
assert_eq!(time.get::<millisecond>(), 123.4);
let time_s = time.get::<second>();
approx::assert_relative_eq!(time_s, 0.1234);
let time_us = time.get::<microsecond>();
approx::assert_relative_eq!(time_us, 123400.0);
}
#[test]
fn test_information() {
use uom::si::information::pebibyte;
let information = Information::new::<kibibyte>(8.);
assert_eq!(information.get::<byte>(), 8192.);
let information_kib = information.get::<kibibyte>();
assert_eq!(information_kib, 8.);
let largest_exactly_representable = Information::new::<byte>(9_007_199_254_740_992.);
assert_eq!(largest_exactly_representable.get::<pebibyte>(), 8.);
}
#[test]
fn test_format_duration_unit_basic() {
let (out_str, out_unit) = format_duration_unit(1.3, None);
assert_eq!("1.300 s", out_str);
assert_eq!(TimeUnit::Second, out_unit);
let (out_str, out_unit) = format_duration_unit(1.0, None);
assert_eq!("1.000 s", out_str);
assert_eq!(TimeUnit::Second, out_unit);
let (out_str, out_unit) = format_duration_unit(0.999, None);
assert_eq!("999.0 ms", out_str);
assert_eq!(TimeUnit::MilliSecond, out_unit);
let (out_str, out_unit) = format_duration_unit(0.0005, None);
assert_eq!("500.0 µs", out_str);
assert_eq!(TimeUnit::MicroSecond, out_unit);
let (out_str, out_unit) = format_duration_unit(0.0, None);
assert_eq!("0.0 µs", out_str);
assert_eq!(TimeUnit::MicroSecond, out_unit);
let (out_str, out_unit) = format_duration_unit(1000.0, None);
assert_eq!("1000.000 s", out_str);
assert_eq!(TimeUnit::Second, out_unit);
}
#[test]
fn test_format_duration_unit_with_unit() {
let (out_str, out_unit) = format_duration_unit(1.3, Some(TimeUnit::Second));
assert_eq!("1.300 s", out_str);
assert_eq!(TimeUnit::Second, out_unit);
let (out_str, out_unit) = format_duration_unit(1.3, Some(TimeUnit::MilliSecond));
assert_eq!("1300.0 ms", out_str);
assert_eq!(TimeUnit::MilliSecond, out_unit);
let (out_str, out_unit) = format_duration_unit(1.3, Some(TimeUnit::MicroSecond));
assert_eq!("1300000.0 µs", out_str);
assert_eq!(TimeUnit::MicroSecond, out_unit);
}
+220
View File
@@ -0,0 +1,220 @@
//! Statistics for non-empty samples of finite measurements.
use std::ops::{Add, AddAssign, Div};
use crate::quantity::{byte, ratio, second, Information, Ratio, Time, Zero};
/// A min function that assumes no NaNs and at least one element
pub fn min<Q: PartialOrd>(values: impl IntoIterator<Item = Q>) -> Q {
values
.into_iter()
.min_by(|a, b| a.partial_cmp(b).expect("No NaN values"))
.expect("'min' requires at least one element")
}
/// A max function that assumes no NaNs and at least one element
pub fn max<Q: PartialOrd>(values: impl IntoIterator<Item = Q>) -> Q {
values
.into_iter()
.max_by(|a, b| a.partial_cmp(b).expect("No NaN values"))
.expect("'max' requires at least one element")
}
/// Arithmetic mean, summing measurements in their original order.
pub fn mean<Q, P>(values: impl IntoIterator<Item = Q>) -> Q
where
Q: AddAssign + Zero + Div<Ratio, Output = P>,
P: Into<Q>,
{
let mut sum = Q::zero();
let mut count = 0usize;
for value in values {
sum += value;
count += 1;
}
let count = Ratio::new::<ratio>(count as f64);
(sum / count).into()
}
/// Median without modifying the input sample.
pub fn median<Q, P>(values: impl IntoIterator<Item = Q>) -> Q
where
Q: Copy + PartialOrd + Add<Output = Q> + Div<Ratio, Output = P>,
P: Into<Q>,
{
let mut values = values.into_iter().collect::<Vec<_>>();
assert!(
!values.is_empty(),
"median requires at least one measurement"
);
values.sort_unstable_by(|a, b| a.partial_cmp(b).expect("No NaN measurements"));
let len = values.len();
if len % 2 == 0 {
let mid = len / 2;
let a = &values[mid - 1];
let b = &values[mid];
((*b + *a) / Ratio::new::<ratio>(2.)).into()
} else {
values[len / 2]
}
}
/// This is just sad, but after several hours trying to figure out how to write
/// a generic `standard_deviation` function using `uom` quantities, I gave up.
pub trait UnsafeRawValue {
fn unsafe_raw_value(&self) -> f64;
fn unsafe_from_raw_value(value: f64) -> Self;
}
impl UnsafeRawValue for Time {
fn unsafe_raw_value(&self) -> f64 {
self.get::<second>()
}
fn unsafe_from_raw_value(value: f64) -> Self {
Time::new::<second>(value)
}
}
impl UnsafeRawValue for Information {
fn unsafe_raw_value(&self) -> f64 {
self.get::<byte>()
}
fn unsafe_from_raw_value(value: f64) -> Self {
Information::new::<byte>(value)
}
}
/// Sample standard deviation, using an already computed mean.
pub fn standard_deviation<Q: UnsafeRawValue>(values: &[Q], mean: Q) -> Q {
let mean = mean.unsafe_raw_value();
assert!(
values.len() > 1,
"standard deviation requires at least two measurements"
);
let sum = values
.iter()
.map(|value| (value.unsafe_raw_value() - mean) * (value.unsafe_raw_value() - mean))
.fold(0.0, |sum, deviation| sum + deviation);
assert!(sum >= 0.0, "invalid sum of squared deviations");
// Preserve the operation order of statistical 1.0: dividing by n - 1
// before taking the square root also preserves exported floating-point values.
Q::unsafe_from_raw_value((sum / (values.len() as f64 - 1.0)).sqrt())
}
/// Compute modifized Z-scores for a given sample. A (unmodified) Z-score is defined by
/// `(x_i - x_mean)/x_stddev` whereas the modified Z-score is defined by `(x_i - x_median)/MAD`
/// where MAD is the median absolute deviation.
///
/// References:
/// - <https://en.wikipedia.org/wiki/Median_absolute_deviation>
pub fn modified_zscores(xs: &[f64]) -> Vec<f64> {
assert!(!xs.is_empty());
// Compute sample median:
let x_median = median(xs.iter().copied());
// Compute the absolute deviations from the median:
let deviations: Vec<f64> = xs.iter().map(|x| (x - x_median).abs()).collect();
// Compute median absolute deviation:
let mad = median(deviations.iter().copied());
// Handle MAD == 0 case
let mad = if mad > 0.0 { mad } else { f64::EPSILON };
// Compute modified Z-scores (x_i - x_median) / MAD
xs.iter().map(|&x| (x - x_median) / mad).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use uom::si::information::kibibyte;
use uom::si::time::{microsecond, millisecond};
#[test]
fn test_min() {
assert_eq!(1.0, min([1.0]));
assert_eq!(-1.0, min([-1.0]));
assert_eq!(-2.0, min([-2.0, -1.0]));
assert_eq!(-1.0, min([-1.0, 1.0]));
assert_eq!(-1.0, min([1.0, -1.0, 0.0]));
let values = vec![
Information::new::<kibibyte>(1.0),
Information::new::<byte>(2.0),
Information::new::<byte>(3.0),
];
assert_eq!(min(&values).get::<byte>(), 2.0);
}
#[test]
fn test_max() {
assert_eq!(1.0, max([1.0]));
assert_eq!(-1.0, max([-1.0]));
assert_eq!(-1.0, max([-2.0, -1.0]));
assert_eq!(1.0, max([-1.0, 1.0]));
assert_eq!(1.0, max([-1.0, 1.0, 0.0]));
let values = vec![
Information::new::<byte>(1.0),
Information::new::<kibibyte>(2.0),
Information::new::<byte>(3.0),
];
assert_eq!(max(&values).get::<kibibyte>(), 2.0);
}
#[test]
fn test_mean() {
assert_eq!(1.0, mean([1.0]));
assert_relative_eq!(2.0, mean([1.0, 3.0]));
let values = [
Time::new::<millisecond>(100.0),
Time::new::<millisecond>(200.0),
Time::new::<microsecond>(600_000.0),
];
let result = mean(values);
assert_relative_eq!(result.get::<millisecond>(), 300.0);
}
#[test]
fn test_median() {
assert_eq!(1.0, median([1.0]));
assert_relative_eq!(2.0, median([1.0, 3.0]));
let values = [
Time::new::<millisecond>(100.0),
Time::new::<millisecond>(200.0),
Time::new::<microsecond>(600_000.0),
];
let result = median(values);
assert_relative_eq!(result.get::<millisecond>(), 200.0);
let values = [
Time::new::<millisecond>(100.0),
Time::new::<millisecond>(200.0),
Time::new::<microsecond>(300_000.0),
Time::new::<microsecond>(600_000.0),
];
let result = median(values);
assert_relative_eq!(result.get::<millisecond>(), 250.0);
}
#[test]
fn test_standard_deviation() {
let values = [
Time::new::<millisecond>(100.0),
Time::new::<millisecond>(200.0),
Time::new::<microsecond>(300_000.0),
];
let result = standard_deviation(&values, mean(values));
assert_relative_eq!(result.get::<millisecond>(), 100.0);
}
}
+56
View File
@@ -0,0 +1,56 @@
//! This module contains common units.
/// Supported time units
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeUnit {
Second,
MilliSecond,
MicroSecond,
Minute,
Hour,
}
impl TimeUnit {
/// The abbreviation of the TimeUnit.
pub fn short_name(self) -> String {
match self {
TimeUnit::Second => String::from("s"),
TimeUnit::MilliSecond => String::from("ms"),
TimeUnit::MicroSecond => String::from("µs"),
TimeUnit::Minute => String::from("min"),
TimeUnit::Hour => String::from("h"),
}
}
/// Returns the value in seconds formatted for the TimeUnit.
pub fn format(self, value: f64) -> String {
match self {
TimeUnit::Second => format!("{value:.3}"),
TimeUnit::MilliSecond => format!("{:.1}", value * 1e3),
TimeUnit::MicroSecond => format!("{:.1}", value * 1e6),
TimeUnit::Minute => format!("{:.1}", value / 60.0),
TimeUnit::Hour => format!("{:.1}", value / 3600.0),
}
}
}
#[test]
fn test_unit_short_name() {
assert_eq!("s", TimeUnit::Second.short_name());
assert_eq!("ms", TimeUnit::MilliSecond.short_name());
assert_eq!("µs", TimeUnit::MicroSecond.short_name());
assert_eq!("min", TimeUnit::Minute.short_name());
assert_eq!("h", TimeUnit::Hour.short_name());
}
// Note - the values are rounded when formatted.
#[test]
fn test_unit_format() {
let value: f64 = 123.456789;
assert_eq!("123.457", TimeUnit::Second.format(value));
assert_eq!("123456.8", TimeUnit::MilliSecond.format(value));
assert_eq!("1234.6", TimeUnit::MicroSecond.format(0.00123456));
assert_eq!("2.1", TimeUnit::Minute.format(value));
assert_eq!("0.0", TimeUnit::Hour.format(value));
}