1use core::num::NonZeroU64;
4use core::str::FromStr;
5
6use derive_more::{Debug, Display};
7#[cfg(feature = "zerocopy")]
8use zerocopy::{Immutable, KnownLayout};
9
10use crate::ParseTickRateError;
11use crate::consts::NANOS_PER_SECOND;
12use crate::spelling::{Count, read_count};
13
14#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[display("{}", Count(hertz.get(), " Hz"))]
33#[debug("{self}")]
34#[cfg_attr(feature = "zerocopy", derive(Immutable, KnownLayout))]
35pub struct TickRate {
36 hertz: NonZeroU64,
38 ticks_to_nanos: Scale,
40 nanos_to_ticks: Scale,
42}
43
44impl TickRate {
45 pub const GIGAHERTZ: Self = Self::from_hertz(1_000_000_000).expect("a billion is no zero");
47
48 #[inline]
50 #[must_use]
51 pub const fn from_hertz(hertz: u64) -> Option<Self> {
52 let Some(nonzero) = NonZeroU64::new(hertz) else {
53 return None;
54 };
55 let nanos_per_second = NANOS_PER_SECOND.unsigned_abs();
56 Some(Self {
57 hertz: nonzero,
58 ticks_to_nanos: Scale::new(nanos_per_second, hertz),
59 nanos_to_ticks: Scale::new(hertz, nanos_per_second),
60 })
61 }
62
63 #[inline]
65 #[must_use]
66 pub const fn as_hertz(self) -> u64 {
67 self.hertz.get()
68 }
69
70 #[inline]
72 pub(crate) const fn ticks_to_nanos(self, ticks: i64) -> i64 {
73 self.ticks_to_nanos.apply(ticks)
74 }
75
76 #[inline]
78 pub(crate) const fn nanos_to_ticks(self, nanos: i64) -> i64 {
79 self.nanos_to_ticks.apply(nanos)
80 }
81}
82
83impl FromStr for TickRate {
85 type Err = ParseTickRateError;
86
87 fn from_str(text: &str) -> Result<Self, Self::Err> {
88 let hertz = text.strip_suffix(" Hz").and_then(read_count);
89 hertz.and_then(Self::from_hertz).ok_or(ParseTickRateError)
90 }
91}
92
93#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
98#[cfg_attr(feature = "zerocopy", derive(Immutable, KnownLayout))]
99struct Scale {
100 factor: u64,
102 shift: u32,
104}
105
106impl Scale {
107 const fn new(numerator: u64, denominator: u64) -> Self {
110 let numerator = widen(numerator);
111 let denominator = widen(denominator);
112 let mut shift = 64_u32
114 .saturating_add(numerator.leading_zeros())
115 .saturating_sub(denominator.leading_zeros());
116 while shift > 0 && !fits_u64(quotient(numerator, denominator, shift)) {
117 shift = shift.saturating_sub(1);
118 }
119 Self { factor: narrow(quotient(numerator, denominator, shift)), shift }
120 }
121
122 #[inline]
124 const fn apply(self, count: i64) -> i64 {
125 let magnitude =
127 widen(count.unsigned_abs()).wrapping_mul(widen(self.factor)).wrapping_shr(self.shift);
128 let magnitude = if fits_u64(magnitude) { narrow(magnitude) } else { u64::MAX };
129 if count < 0 {
130 0_i64.saturating_sub_unsigned(magnitude)
131 } else {
132 0_i64.saturating_add_unsigned(magnitude)
133 }
134 }
135}
136
137const fn quotient(numerator: u128, denominator: u128, shift: u32) -> u128 {
139 numerator.wrapping_shl(shift).div_ceil(denominator)
140}
141
142const fn widen(value: u64) -> u128 {
144 #[expect(clippy::as_conversions, reason = "widening is lossless; `From` is not const")]
145 let widened = value as u128;
146 widened
147}
148
149const fn fits_u64(value: u128) -> bool {
151 value <= widen(u64::MAX)
152}
153
154const fn narrow(value: u128) -> u64 {
156 #[expect(
157 clippy::as_conversions,
158 clippy::cast_possible_truncation,
159 reason = "every caller checks the value fits; `TryFrom` is not const"
160 )]
161 let narrowed = value as u64;
162 narrowed
163}
164
165#[cfg(test)]
166mod tests {
167 use alloc::format;
168 use alloc::string::ToString as _;
169
170 use proptest::prelude::{any, prop_assert, proptest};
171 use rstest::rstest;
172
173 use crate::{ParseTickRateError, TickRate, Tickdelta, Timedelta};
174
175 fn rate(hertz: u64) -> TickRate {
177 TickRate::from_hertz(hertz).expect("a nonzero rate")
178 }
179
180 fn exact(count: i64, numerator: u64, denominator: u64) -> i128 {
182 i128::from(count)
183 .checked_mul(i128::from(numerator))
184 .and_then(|product| product.checked_div(i128::from(denominator)))
185 .expect("a 64-bit product fits 128 bits, over a nonzero denominator")
186 }
187
188 #[rstest]
189 #[case::apple_silicon(24_000_000)]
190 #[case::armv8_6(1_000_000_000)]
191 #[case::a_tsc(3_000_000_000)]
192 #[case::a_tsc_from_its_crystal(3_379_200_000)]
193 fn a_second_of_ticks_is_a_second(#[case] hertz: u64) {
194 let second = Tickdelta::from_ticks(i64::try_from(hertz).expect("a rate below `i64::MAX`"));
195 assert_eq!(second.to_timedelta(rate(hertz)), Timedelta::SECOND, "a second of ticks");
196 assert_eq!(Timedelta::SECOND.to_tickdelta(rate(hertz)), second, "and back, exactly");
197 }
198
199 #[test]
200 fn a_backwards_count_converts_backwards() {
201 let span = Tickdelta::from_ticks(-24).to_timedelta(rate(24_000_000));
202 assert_eq!(span, -Timedelta::MICROSECOND, "ticks to nanoseconds");
203 let ticks = (-Timedelta::MICROSECOND).to_tickdelta(rate(3_000_000_000));
204 assert_eq!(ticks, Tickdelta::from_ticks(-3_000), "and nanoseconds to ticks");
205 }
206
207 #[test]
208 fn a_conversion_past_the_range_saturates() {
209 assert_eq!(Tickdelta::MAX.to_timedelta(rate(1)), Timedelta::MAX, "forwards");
210 assert_eq!(Tickdelta::MIN.to_timedelta(rate(1)), Timedelta::MIN, "backwards");
211 assert_eq!(
212 Timedelta::MAX.to_tickdelta(rate(u64::MAX)),
213 Tickdelta::MAX,
214 "the other way about"
215 );
216 }
217
218 #[test]
219 fn a_rate_reads_back_from_its_hertz() {
220 assert_eq!(rate(24_000_000).to_string(), "24000000 Hz", "written as its hertz");
221 assert_eq!("24000000 Hz".parse(), Ok(rate(24_000_000)), "and read back");
222 assert_eq!(TickRate::GIGAHERTZ.as_hertz(), 1_000_000_000, "a gigahertz in hertz");
223 assert_eq!(TickRate::from_hertz(0), None, "a counter that never ticks");
224 }
225
226 #[test]
227 fn a_width_pads_the_rate_with_its_unit() {
228 assert_eq!(format!("[{:>8}]", rate(24)), "[ 24 Hz]", "to the right");
229 assert_eq!(format!("{:?}", rate(24)), "24 Hz", "and debugged as displayed");
230 }
231
232 #[rstest]
233 #[case::zero("0 Hz")]
234 #[case::no_unit("24000000")]
235 #[case::a_sign_display_never_writes("+24 Hz")]
236 #[case::kilohertz("24 kHz")]
237 #[case::past_the_range("18446744073709551616 Hz")]
238 fn a_malformed_rate_is_refused(#[case] text: &str) {
239 assert_eq!(text.parse::<TickRate>(), Err(ParseTickRateError), "{text:?} is refused");
240 }
241
242 proptest! {
243 #[test]
244 fn a_conversion_is_within_one_unit_of_the_exact_quotient(
245 count in any::<i64>(),
246 hertz in 1_u64..,
247 ) {
248 let rate = rate(hertz);
249 let nanos = Tickdelta::from_ticks(count).to_timedelta(rate).as_nanos();
250 let ticks = Timedelta::from_nanos(count).to_tickdelta(rate).as_ticks();
251 for (actual, exact) in [
252 (nanos, exact(count, 1_000_000_000, hertz)),
253 (ticks, exact(count, hertz, 1_000_000_000)),
254 ] {
255 let exact = exact.clamp(i128::from(i64::MIN), i128::from(i64::MAX));
256 prop_assert!(i128::from(actual).abs_diff(exact) <= 1, "{actual} against {exact}");
257 }
258 }
259 }
260}