Skip to main content

polydat_nodes/
round_numbers.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Round-number selector nodes.
5//!
6//! General-purpose "snap this magnitude to a nice round number" math on
7//! f64 values. Four scale families — powers of ten (`base10`), multiples
8//! of the base-ten magnitude (`decade`), Fibonacci numbers (`fibonacci`),
9//! and powers of two (`binomial`) — each in `floor_` / `ceiling_` /
10//! `closest_` variants, plus a general arbitrary-`interval` rounder.
11//!
12//! These are pure numeric utilities (no adapter, no prefix), used to pick
13//! human-friendly axis ticks, bucket boundaries, and magnitude labels.
14//!
15//! Edge handling: every family selector returns `0.0` for `x <= 0` and for
16//! non-finite `x` — no panics, no NaN/inf leaking. Fractional `x` in `(0,1)`
17//! yields fractional powers for `base10`/`binomial` (e.g. `floor_base10(0.5)
18//! = 0.1`), which is correct and preserved. The interval rounders return
19//! `x` unchanged when `interval` is non-positive or non-finite (identity —
20//! never divide by zero).
21
22pub use polydat::numeric::round_numbers::{
23    ceiling_fibonacci_val, floor_fibonacci_val, floor_pow2, floor_pow10, pick_closest,
24    positive_finite,
25};
26
27// ---------------------------------------------------------------------------
28// base10 — powers of ten (10^n).
29// ---------------------------------------------------------------------------
30
31/// Largest power of ten `<= x`: `10^floor(log10(x))`. `x <= 0` → `0.0`.
32#[polydat::polydat_node(category = Math)]
33pub(crate) fn floor_base10(x: f64) -> f64 {
34    if !positive_finite(x) {
35        return 0.0;
36    }
37    floor_pow10(x)
38}
39
40/// Smallest power of ten `>= x`: `10^ceil(log10(x))`. `x <= 0` → `0.0`.
41#[polydat::polydat_node(category = Math)]
42pub(crate) fn ceiling_base10(x: f64) -> f64 {
43    if !positive_finite(x) {
44        return 0.0;
45    }
46    let lo = floor_pow10(x);
47    if lo == x { lo } else { lo * 10.0 }
48}
49
50/// Power of ten nearest to `x` by absolute distance (ties → floor).
51/// `x <= 0` → `0.0`.
52#[polydat::polydat_node(category = Math)]
53pub(crate) fn closest_base10(x: f64) -> f64 {
54    if !positive_finite(x) {
55        return 0.0;
56    }
57    let lo = floor_pow10(x);
58    let hi = if lo == x { lo } else { lo * 10.0 };
59    pick_closest(x, lo, hi)
60}
61
62// ---------------------------------------------------------------------------
63// decade — multiples of the base-ten magnitude `base = 10^floor(log10(x))`.
64// ---------------------------------------------------------------------------
65
66/// Round `x` down to a multiple of its base-ten magnitude:
67/// `floor(x/base)*base`. `x <= 0` → `0.0`.
68#[polydat::polydat_node(category = Math)]
69pub(crate) fn floor_decade(x: f64) -> f64 {
70    if !positive_finite(x) {
71        return 0.0;
72    }
73    let base = floor_pow10(x);
74    (x / base).floor() * base
75}
76
77/// Round `x` up to a multiple of its base-ten magnitude:
78/// `ceil(x/base)*base`. `x <= 0` → `0.0`.
79#[polydat::polydat_node(category = Math)]
80pub(crate) fn ceiling_decade(x: f64) -> f64 {
81    if !positive_finite(x) {
82        return 0.0;
83    }
84    let base = floor_pow10(x);
85    (x / base).ceil() * base
86}
87
88/// Round `x` to the nearest multiple of its base-ten magnitude:
89/// `round(x/base)*base`. `x <= 0` → `0.0`.
90#[polydat::polydat_node(category = Math)]
91pub(crate) fn closest_decade(x: f64) -> f64 {
92    if !positive_finite(x) {
93        return 0.0;
94    }
95    let base = floor_pow10(x);
96    (x / base).round() * base
97}
98
99// ---------------------------------------------------------------------------
100// fibonacci — Fibonacci numbers 1, 2, 3, 5, 8, 13, … (start 1, 2).
101// ---------------------------------------------------------------------------
102
103/// Largest Fibonacci number `<= x`. `x < 1` (incl. `x <= 0`) → `0.0`.
104#[polydat::polydat_node(category = Math)]
105pub(crate) fn floor_fibonacci(x: f64) -> f64 {
106    if !positive_finite(x) {
107        return 0.0;
108    }
109    floor_fibonacci_val(x)
110}
111
112/// Smallest Fibonacci number `>= x`. `x <= 0` → `0.0`.
113#[polydat::polydat_node(category = Math)]
114pub(crate) fn ceiling_fibonacci(x: f64) -> f64 {
115    if !positive_finite(x) {
116        return 0.0;
117    }
118    ceiling_fibonacci_val(x)
119}
120
121/// Fibonacci number nearest to `x` by absolute distance (ties → floor).
122/// `x <= 0` → `0.0`.
123#[polydat::polydat_node(category = Math)]
124pub(crate) fn closest_fibonacci(x: f64) -> f64 {
125    if !positive_finite(x) {
126        return 0.0;
127    }
128    pick_closest(x, floor_fibonacci_val(x), ceiling_fibonacci_val(x))
129}
130
131// ---------------------------------------------------------------------------
132// binomial — powers of two (2^n).
133// ---------------------------------------------------------------------------
134
135/// Largest power of two `<= x`: `2^floor(log2(x))`. `x <= 0` → `0.0`.
136#[polydat::polydat_node(category = Math)]
137pub(crate) fn floor_binomial(x: f64) -> f64 {
138    if !positive_finite(x) {
139        return 0.0;
140    }
141    floor_pow2(x)
142}
143
144/// Smallest power of two `>= x`: `2^ceil(log2(x))`. `x <= 0` → `0.0`.
145#[polydat::polydat_node(category = Math)]
146pub(crate) fn ceiling_binomial(x: f64) -> f64 {
147    if !positive_finite(x) {
148        return 0.0;
149    }
150    let lo = floor_pow2(x);
151    if lo == x { lo } else { lo * 2.0 }
152}
153
154/// Power of two nearest to `x` by absolute distance (ties → floor).
155/// `x <= 0` → `0.0`.
156#[polydat::polydat_node(category = Math)]
157pub(crate) fn closest_binomial(x: f64) -> f64 {
158    if !positive_finite(x) {
159        return 0.0;
160    }
161    let lo = floor_pow2(x);
162    let hi = if lo == x { lo } else { lo * 2.0 };
163    pick_closest(x, lo, hi)
164}
165
166// ---------------------------------------------------------------------------
167// General arbitrary-interval rounders.
168// ---------------------------------------------------------------------------
169
170/// Round `x` down to a multiple of `interval`: `floor(x/interval)*interval`.
171/// `interval <= 0` or non-finite → returns `x` unchanged (identity).
172#[polydat::polydat_node(category = Math)]
173pub(crate) fn round_floor(x: f64, interval: f64) -> f64 {
174    if !(interval.is_finite() && interval > 0.0) {
175        return x;
176    }
177    (x / interval).floor() * interval
178}
179
180/// Round `x` up to a multiple of `interval`: `ceil(x/interval)*interval`.
181/// `interval <= 0` or non-finite → returns `x` unchanged (identity).
182#[polydat::polydat_node(category = Math)]
183pub(crate) fn round_ceiling(x: f64, interval: f64) -> f64 {
184    if !(interval.is_finite() && interval > 0.0) {
185        return x;
186    }
187    (x / interval).ceil() * interval
188}
189
190/// Round `x` to the nearest multiple of `interval`: `round(x/interval)*interval`.
191/// `interval <= 0` or non-finite → returns `x` unchanged (identity).
192#[polydat::polydat_node(category = Math)]
193pub(crate) fn round_nearest(x: f64, interval: f64) -> f64 {
194    if !(interval.is_finite() && interval > 0.0) {
195        return x;
196    }
197    (x / interval).round() * interval
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use polydat::ast::{PolydatNode, Value};
204
205    fn run1(node: &dyn PolydatNode, x: f64) -> f64 {
206        let mut out = [Value::None];
207        node.eval(&[Value::F64(x)], &mut out);
208        out[0].as_f64()
209    }
210
211    fn run2(node: &dyn PolydatNode, x: f64, interval: f64) -> f64 {
212        let mut out = [Value::None];
213        node.eval(&[Value::F64(x), Value::F64(interval)], &mut out);
214        out[0].as_f64()
215    }
216
217    // ── base10 ────────────────────────────────────────────
218    #[test]
219    fn base10_vectors() {
220        assert_eq!(run1(&FloorBase10::new(), 1732.234), 1000.0);
221        assert_eq!(run1(&CeilingBase10::new(), 1732.0), 10000.0);
222        assert_eq!(run1(&ClosestBase10::new(), 1732.0), 1000.0);
223        assert_eq!(run1(&ClosestBase10::new(), 6000.0), 10000.0);
224    }
225
226    #[test]
227    fn base10_exact_power_is_stable() {
228        // On an exact power of ten, floor == ceiling == x.
229        assert_eq!(run1(&FloorBase10::new(), 1000.0), 1000.0);
230        assert_eq!(run1(&CeilingBase10::new(), 1000.0), 1000.0);
231    }
232
233    #[test]
234    fn base10_fractional_below_one() {
235        // (0,1): powers of ten stay fractional — keep it.
236        assert!((run1(&FloorBase10::new(), 0.5) - 0.1).abs() < 1e-12);
237    }
238
239    // ── decade ────────────────────────────────────────────
240    #[test]
241    fn decade_vectors() {
242        assert_eq!(run1(&FloorDecade::new(), 2734.0), 2000.0);
243        assert_eq!(run1(&CeilingDecade::new(), 2734.0), 3000.0);
244        assert_eq!(run1(&ClosestDecade::new(), 2734.0), 3000.0);
245        assert_eq!(run1(&FloorDecade::new(), 1732.0), 1000.0);
246        assert_eq!(run1(&ClosestDecade::new(), 1732.0), 2000.0);
247        assert_eq!(run1(&CeilingDecade::new(), 1732.0), 2000.0);
248    }
249
250    // ── fibonacci ─────────────────────────────────────────
251    #[test]
252    fn fibonacci_vectors() {
253        assert_eq!(run1(&FloorFibonacci::new(), 1732.0), 1597.0);
254        assert_eq!(run1(&CeilingFibonacci::new(), 1732.0), 2584.0);
255        assert_eq!(run1(&ClosestFibonacci::new(), 1732.0), 1597.0);
256    }
257
258    #[test]
259    fn fibonacci_floor_below_one_is_zero() {
260        assert_eq!(run1(&FloorFibonacci::new(), 0.5), 0.0);
261    }
262
263    #[test]
264    fn fibonacci_exact_member_is_stable() {
265        assert_eq!(run1(&FloorFibonacci::new(), 1597.0), 1597.0);
266        assert_eq!(run1(&CeilingFibonacci::new(), 1597.0), 1597.0);
267    }
268
269    // ── binomial ──────────────────────────────────────────
270    #[test]
271    fn binomial_vectors() {
272        assert_eq!(run1(&FloorBinomial::new(), 1732.0), 1024.0);
273        assert_eq!(run1(&CeilingBinomial::new(), 1732.0), 2048.0);
274        assert_eq!(run1(&ClosestBinomial::new(), 1732.0), 2048.0);
275    }
276
277    #[test]
278    fn binomial_exact_power_is_stable() {
279        assert_eq!(run1(&FloorBinomial::new(), 1024.0), 1024.0);
280        assert_eq!(run1(&CeilingBinomial::new(), 1024.0), 1024.0);
281    }
282
283    // ── non-positive / non-finite edges ───────────────────
284    #[test]
285    fn non_positive_inputs_are_zero() {
286        assert_eq!(run1(&FloorBase10::new(), 0.0), 0.0);
287        assert_eq!(run1(&FloorBase10::new(), -5.0), 0.0);
288        assert_eq!(run1(&CeilingBinomial::new(), -1.0), 0.0);
289        assert_eq!(run1(&ClosestFibonacci::new(), 0.0), 0.0);
290        assert_eq!(run1(&ClosestDecade::new(), -1000.0), 0.0);
291        assert_eq!(run1(&FloorBase10::new(), f64::INFINITY), 0.0);
292        assert_eq!(run1(&FloorBinomial::new(), f64::NAN), 0.0);
293    }
294
295    // ── general interval rounders ─────────────────────────
296    #[test]
297    fn round_interval_vectors() {
298        assert_eq!(run2(&RoundFloor::new(), 1732.0, 500.0), 1500.0);
299        assert_eq!(run2(&RoundCeiling::new(), 1732.0, 500.0), 2000.0);
300        // (1732/500).round() = 3, so nearest multiple of 500 is 1500.
301        assert_eq!(run2(&RoundNearest::new(), 1732.0, 500.0), 1500.0);
302        assert_eq!(run2(&RoundNearest::new(), 1700.0, 500.0), 1500.0);
303        // interval <= 0 → identity.
304        assert_eq!(run2(&RoundFloor::new(), 1732.0, 0.0), 1732.0);
305    }
306
307    #[test]
308    fn round_interval_identity_on_bad_interval() {
309        assert_eq!(run2(&RoundNearest::new(), 1732.0, -5.0), 1732.0);
310        assert_eq!(run2(&RoundCeiling::new(), 1732.0, f64::INFINITY), 1732.0);
311        assert_eq!(run2(&RoundFloor::new(), 1732.0, f64::NAN), 1732.0);
312    }
313
314    // ── registry discovery ────────────────────────────────
315    #[test]
316    fn all_fifteen_registered_under_math() {
317        for name in [
318            "floor_base10",
319            "ceiling_base10",
320            "closest_base10",
321            "floor_decade",
322            "ceiling_decade",
323            "closest_decade",
324            "floor_fibonacci",
325            "ceiling_fibonacci",
326            "closest_fibonacci",
327            "floor_binomial",
328            "ceiling_binomial",
329            "closest_binomial",
330            "round_floor",
331            "round_ceiling",
332            "round_nearest",
333        ] {
334            let sig = polydat::dsl::registry::lookup(name)
335                .unwrap_or_else(|| panic!("node '{name}' not registered"));
336            assert_eq!(
337                sig.category,
338                polydat::dsl::registry::FuncCategory::Math,
339                "node '{name}' registered under wrong category",
340            );
341        }
342    }
343}