Skip to main content

siplot/core/
dtime_ticks.rs

1//! Date-time tick layout on a numeric axis whose data values are epoch
2//! seconds (UTC).
3//!
4//! A faithful port of silx `gui/plot/_utils/dtime_ticklayout.py`. The silx
5//! original operates on `datetime.datetime` objects backed by `dateutil`; here
6//! the data values are `f64` POSIX timestamps (seconds since 1970-01-01
7//! 00:00:00 UTC) and the civil-date arithmetic is implemented directly via the
8//! standard "days from civil" algorithm (Howard Hinnant,
9//! <http://howardhinnant.github.io/date_algorithms.html>), so the civil
10//! arithmetic itself carries no `chrono` dependency.
11//!
12//! Per-axis time zone: every entry point has a `_tz` variant taking a
13//! [`TimeZone`], mirroring silx `Axis.setTimeZone`. The legacy non-`_tz`
14//! functions are [`TimeZone::Utc`] wrappers, so existing UTC callers are
15//! unchanged. [`TimeZone`] covers the full silx surface: [`TimeZone::Utc`]
16//! (`"UTC"`) and [`TimeZone::FixedOffset`] (`datetime.timezone(timedelta(...))`)
17//! need no tz database, while [`TimeZone::Named`] (an arbitrary `dateutil.tz`
18//! zone) and [`TimeZone::local`] (`None`/local time) use the bundled IANA tz
19//! database (`tzdb`/`tz-rs`) for an instant-dependent, DST-aware offset. The
20//! layout code is identical across zones — only the single offset lookup
21//! [`TimeZone::offset_at`] varies.
22//!
23//! The two entry points mirror the Python module:
24//!
25//! - [`best_unit`] (`bestUnit`): pick the tick unit + a fractional count for a
26//!   duration in seconds.
27//! - [`calc_ticks`] (`calcTicks`): given `(min, max)` epoch seconds and a
28//!   target tick count, return the tick positions (epoch seconds), the nice
29//!   spacing, and the chosen unit.
30//!
31//! [`format_tick`] mirrors `bestFormatString` + `strftime` for a single tick.
32
33// Constants mirror silx dtime_ticklayout.py:49-54.
34const MICROSECONDS_PER_SECOND: f64 = 1_000_000.0;
35const SECONDS_PER_MINUTE: f64 = 60.0;
36const SECONDS_PER_HOUR: f64 = 60.0 * SECONDS_PER_MINUTE;
37const SECONDS_PER_DAY: f64 = 24.0 * SECONDS_PER_HOUR;
38const SECONDS_PER_YEAR: f64 = 365.25 * SECONDS_PER_DAY;
39const SECONDS_PER_MONTH_AVERAGE: f64 = SECONDS_PER_YEAR / 12.0;
40
41/// The unit a tick step is expressed in (silx `DtUnit`). Discriminants match
42/// silx so the `< unit.value` ordering comparisons in `round_to_element` port
43/// directly (`YEARS = 0` … `MICRO_SECONDS = 6`).
44#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
45pub enum DtUnit {
46    /// Calendar years.
47    Years = 0,
48    /// Calendar months.
49    Months = 1,
50    /// Days.
51    Days = 2,
52    /// Hours.
53    Hours = 3,
54    /// Minutes.
55    Minutes = 4,
56    /// Seconds.
57    Seconds = 5,
58    /// A fraction of a second.
59    MicroSeconds = 6,
60}
61
62impl DtUnit {
63    /// The "nice" step values silx allows for this unit
64    /// (`NICE_DATE_VALUES`, dtime_ticklayout.py:288-296). The last entry is the
65    /// base used by [`nice_num_generic`].
66    fn nice_values(self) -> &'static [f64] {
67        match self {
68            DtUnit::Years => &[1.0, 2.0, 5.0, 10.0],
69            DtUnit::Months => &[1.0, 2.0, 3.0, 4.0, 6.0, 12.0],
70            DtUnit::Days => &[1.0, 2.0, 3.0, 7.0, 14.0, 28.0],
71            DtUnit::Hours => &[1.0, 2.0, 3.0, 4.0, 6.0, 12.0],
72            DtUnit::Minutes => &[1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0],
73            DtUnit::Seconds => &[1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0],
74            DtUnit::MicroSeconds => &[1.0, 2.0, 3.0, 4.0, 5.0, 10.0],
75        }
76    }
77}
78
79/// The time zone a date-time axis is laid out in (silx `Axis.setTimeZone`).
80///
81/// silx accepts any `datetime.tzinfo`, the string `"UTC"`, or `None` (local
82/// time). The variants cover that surface:
83///
84/// - [`TimeZone::Utc`] — silx `"UTC"`; zero offset, no tz database needed.
85/// - [`TimeZone::FixedOffset`] — silx `datetime.timezone(timedelta(...))`; a
86///   constant offset, no tz database needed.
87/// - [`TimeZone::Named`] — a DST-aware IANA zone (e.g. `America/New_York`)
88///   resolved from the bundled tz database; the offset is instant-dependent.
89///   Build with [`TimeZone::named`]; silx `None`/local time is
90///   [`TimeZone::local`].
91///
92/// The single offset lookup is [`TimeZone::offset_at`]; decompose, recompose,
93/// and tick layout are all expressed in terms of it.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum TimeZone {
96    /// Coordinated Universal Time — zero offset (silx `setTimeZone("UTC")`).
97    Utc,
98    /// A constant offset east of UTC, in seconds (silx
99    /// `setTimeZone(datetime.timezone(timedelta(seconds=seconds_east)))`).
100    /// Positive is east (e.g. `+32400` for UTC+09:00); negative is west.
101    FixedOffset {
102        /// Seconds east of UTC. The wall-clock time equals UTC plus this.
103        seconds_east: i32,
104    },
105    /// A DST-aware named IANA zone from the bundled tz database (silx accepting
106    /// an arbitrary `dateutil.tz` zone). The borrowed [`tz::TimeZoneRef`] points
107    /// into the statically-compiled database, so it stays `Copy`. Construct via
108    /// [`TimeZone::named`] / [`TimeZone::local`].
109    Named(tz::TimeZoneRef<'static>),
110}
111
112impl TimeZone {
113    /// Resolve an IANA zone name (e.g. `"America/New_York"`, `"Europe/Paris"`)
114    /// from the bundled tz database into a [`TimeZone::Named`]. Returns `None`
115    /// for an unknown name (silx would raise on an invalid tzinfo).
116    pub fn named(name: &str) -> Option<TimeZone> {
117        tzdb::tz_by_name(name).map(TimeZone::Named)
118    }
119
120    /// The system's local time zone as a [`TimeZone::Named`] (silx
121    /// `setTimeZone(None)`, "interpret as local time"). Returns `None` if the
122    /// local zone cannot be determined.
123    pub fn local() -> Option<TimeZone> {
124        tzdb::local_tz().map(TimeZone::Named)
125    }
126
127    /// The UTC offset in seconds (east positive) that applies at the given UTC
128    /// instant `epoch_utc` (POSIX seconds). For [`TimeZone::Utc`] and
129    /// [`TimeZone::FixedOffset`] the offset is constant and the argument is
130    /// ignored; for [`TimeZone::Named`] it is looked up in the tz database
131    /// (`find_local_time_type`), falling back to `0` only if the instant is
132    /// outside the database's representable range.
133    pub fn offset_at(self, epoch_utc: f64) -> i32 {
134        match self {
135            TimeZone::Utc => 0,
136            TimeZone::FixedOffset { seconds_east } => seconds_east,
137            TimeZone::Named(tz) => tz
138                .find_local_time_type(epoch_utc.floor() as i64)
139                .map(|lt| lt.ut_offset())
140                .unwrap_or(0),
141        }
142    }
143}
144
145/// A UTC civil date-time decomposed to the resolution silx uses (microseconds).
146///
147/// This is the Rust stand-in for the `datetime.datetime` objects silx passes
148/// around. Fields are not range-validated on construction beyond what
149/// [`DateTime::from_civil`] guarantees; callers go through the constructors.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct DateTime {
152    /// Civil year (may be negative; proleptic Gregorian).
153    pub year: i64,
154    /// Month, 1-12.
155    pub month: u32,
156    /// Day of month, 1-31.
157    pub day: u32,
158    /// Hour, 0-23.
159    pub hour: u32,
160    /// Minute, 0-59.
161    pub minute: u32,
162    /// Second, 0-59.
163    pub second: u32,
164    /// Microsecond, 0-999_999.
165    pub microsecond: u32,
166}
167
168/// Number of days from the civil epoch (1970-01-01) to `y-m-d`.
169///
170/// Howard Hinnant's `days_from_civil`. Valid for any proleptic Gregorian date;
171/// `m ∈ [1, 12]`, `d ∈ [1, last day of month]`. Negative results are days
172/// before the epoch.
173fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
174    // Shift so the year starts in March (leap day at year end).
175    let y = if m <= 2 { y - 1 } else { y };
176    let era = if y >= 0 { y } else { y - 399 } / 400;
177    let yoe = y - era * 400; // [0, 399]
178    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as i64 + 2) / 5 + d as i64 - 1; // [0, 365]
179    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
180    era * 146097 + doe - 719468
181}
182
183/// Inverse of [`days_from_civil`]: civil `(year, month, day)` for a day count
184/// relative to 1970-01-01. Howard Hinnant's `civil_from_days`.
185fn civil_from_days(z: i64) -> (i64, u32, u32) {
186    let z = z + 719468;
187    let era = if z >= 0 { z } else { z - 146096 } / 146097;
188    let doe = z - era * 146097; // [0, 146096]
189    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
190    let y = yoe + era * 400;
191    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
192    let mp = (5 * doy + 2) / 153; // [0, 11]
193    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
194    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
195    let y = if m <= 2 { y + 1 } else { y };
196    (y, m, d)
197}
198
199/// Days in `month` of `year` (proleptic Gregorian).
200fn days_in_month(year: i64, month: u32) -> u32 {
201    match month {
202        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
203        4 | 6 | 9 | 11 => 30,
204        2 => {
205            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
206            if leap { 29 } else { 28 }
207        }
208        _ => 30,
209    }
210}
211
212impl DateTime {
213    /// Build from civil components without normalization. The day is clamped to
214    /// the last valid day of the month (mirrors how silx's `setDateElement`
215    /// would otherwise raise; here a clamp keeps tick generation total).
216    pub fn from_civil(
217        year: i64,
218        month: u32,
219        day: u32,
220        hour: u32,
221        minute: u32,
222        second: u32,
223        microsecond: u32,
224    ) -> Self {
225        let month = month.clamp(1, 12);
226        let day = day.clamp(1, days_in_month(year, month));
227        Self {
228            year,
229            month,
230            day,
231            hour,
232            minute,
233            second,
234            microsecond,
235        }
236    }
237
238    /// Decompose an epoch-seconds timestamp (UTC) into civil components.
239    ///
240    /// Uses floor division so timestamps before the epoch decompose correctly
241    /// (e.g. `-0.5` is `1969-12-31 23:59:59.500000`).
242    pub fn from_epoch_seconds(epoch: f64) -> Self {
243        // Split into whole seconds (floor) and sub-second microseconds so the
244        // microsecond field is always in [0, 999_999].
245        let whole = epoch.floor();
246        let frac = epoch - whole; // [0, 1)
247        let microsecond = (frac * MICROSECONDS_PER_SECOND).round() as i64;
248        // Rounding can push frac up to a full second.
249        let (mut total_secs, microsecond) = if microsecond >= 1_000_000 {
250            (whole as i64 + 1, 0u32)
251        } else {
252            (whole as i64, microsecond as u32)
253        };
254
255        let days = total_secs.div_euclid(SECONDS_PER_DAY as i64);
256        let sod = total_secs.rem_euclid(SECONDS_PER_DAY as i64); // seconds of day, [0, 86399]
257        total_secs = sod;
258        let hour = (total_secs / 3600) as u32;
259        let minute = ((total_secs % 3600) / 60) as u32;
260        let second = (total_secs % 60) as u32;
261        let (year, month, day) = civil_from_days(days);
262        Self {
263            year,
264            month,
265            day,
266            hour,
267            minute,
268            second,
269            microsecond,
270        }
271    }
272
273    /// Convert back to epoch seconds (UTC). Exact inverse of
274    /// [`DateTime::from_epoch_seconds`] for in-range civil dates.
275    pub fn to_epoch_seconds(self) -> f64 {
276        let days = days_from_civil(self.year, self.month, self.day);
277        let secs = days * (SECONDS_PER_DAY as i64)
278            + self.hour as i64 * 3600
279            + self.minute as i64 * 60
280            + self.second as i64;
281        secs as f64 + self.microsecond as f64 / MICROSECONDS_PER_SECOND
282    }
283
284    /// Decompose an epoch-seconds timestamp into the wall-clock civil
285    /// components of `tz` — silx `datetime.fromtimestamp(epoch, tz=...)`.
286    ///
287    /// The epoch is shifted east by the zone offset at that instant and then
288    /// decomposed as a UTC civil date-time, so the resulting fields read as the
289    /// local wall clock. [`TimeZone::Utc`] reduces to
290    /// [`DateTime::from_epoch_seconds`].
291    pub fn from_epoch_seconds_tz(epoch: f64, tz: TimeZone) -> Self {
292        DateTime::from_epoch_seconds(epoch + tz.offset_at(epoch) as f64)
293    }
294
295    /// Convert a wall-clock civil date-time in `tz` back to epoch seconds —
296    /// silx `datetime.timestamp()` on a tz-aware datetime. Inverse of
297    /// [`DateTime::from_epoch_seconds_tz`].
298    ///
299    /// For a DST-aware zone the offset depends on the local wall time, which is
300    /// what we hold (not the UTC instant). We treat the wall clock as if it were
301    /// UTC to get a first offset estimate, then refine once at the corrected
302    /// instant — the standard two-step local→UTC inversion. For a constant
303    /// zone both steps return the same offset, so this is exact.
304    pub fn to_epoch_seconds_tz(self, tz: TimeZone) -> f64 {
305        let wall = self.to_epoch_seconds();
306        let off1 = tz.offset_at(wall);
307        let off2 = tz.offset_at(wall - off1 as f64);
308        wall - off2 as f64
309    }
310
311    /// The integer value of the date element for `unit` (silx
312    /// `getDateElement`).
313    fn get_element(self, unit: DtUnit) -> i64 {
314        match unit {
315            DtUnit::Years => self.year,
316            DtUnit::Months => self.month as i64,
317            DtUnit::Days => self.day as i64,
318            DtUnit::Hours => self.hour as i64,
319            DtUnit::Minutes => self.minute as i64,
320            DtUnit::Seconds => self.second as i64,
321            DtUnit::MicroSeconds => self.microsecond as i64,
322        }
323    }
324
325    /// Return a copy with the element for `unit` set to `value` (silx
326    /// `setDateElement`). The day is clamped via [`DateTime::from_civil`].
327    fn set_element(self, value: i64, unit: DtUnit) -> Self {
328        let mut dt = self;
329        match unit {
330            DtUnit::Years => dt.year = value,
331            DtUnit::Months => dt.month = value as u32,
332            DtUnit::Days => dt.day = value as u32,
333            DtUnit::Hours => dt.hour = value as u32,
334            DtUnit::Minutes => dt.minute = value as u32,
335            DtUnit::Seconds => dt.second = value as u32,
336            DtUnit::MicroSeconds => dt.microsecond = value as u32,
337        }
338        DateTime::from_civil(
339            dt.year,
340            dt.month,
341            dt.day,
342            dt.hour,
343            dt.minute,
344            dt.second,
345            dt.microsecond,
346        )
347    }
348
349    /// Round down to `unit`: zero out every element finer than `unit` (silx
350    /// `roundToElement`). Years are never rounded; month/day floor to 1, the
351    /// time fields floor to 0.
352    fn round_to_element(self, unit: DtUnit) -> Self {
353        let u = unit as i64;
354        let month = if u < DtUnit::Months as i64 {
355            1
356        } else {
357            self.month
358        };
359        let day = if u < DtUnit::Days as i64 { 1 } else { self.day };
360        let hour = if u < DtUnit::Hours as i64 {
361            0
362        } else {
363            self.hour
364        };
365        let minute = if u < DtUnit::Minutes as i64 {
366            0
367        } else {
368            self.minute
369        };
370        let second = if u < DtUnit::Seconds as i64 {
371            0
372        } else {
373            self.second
374        };
375        let microsecond = if u < DtUnit::MicroSeconds as i64 {
376            0
377        } else {
378            self.microsecond
379        };
380        DateTime::from_civil(self.year, month, day, hour, minute, second, microsecond)
381    }
382
383    /// Add `value` of `unit` to this date-time (silx `addValueToDate`). Year and
384    /// month additions truncate `value` to an integer (relativedelta has no
385    /// fractional year/month); calendar overflow rolls correctly.
386    fn add_value(self, value: f64, unit: DtUnit) -> Self {
387        match unit {
388            DtUnit::Years => {
389                let n = value as i64;
390                DateTime::from_civil(
391                    self.year + n,
392                    self.month,
393                    self.day,
394                    self.hour,
395                    self.minute,
396                    self.second,
397                    self.microsecond,
398                )
399            }
400            DtUnit::Months => {
401                let n = value as i64;
402                // Total months since year 0, then re-split. relativedelta
403                // clamps the day to the target month's length, which
404                // from_civil reproduces via its day clamp.
405                let total = (self.year) * 12 + (self.month as i64 - 1) + n;
406                let year = total.div_euclid(12);
407                let month = (total.rem_euclid(12) + 1) as u32;
408                DateTime::from_civil(
409                    year,
410                    month,
411                    self.day,
412                    self.hour,
413                    self.minute,
414                    self.second,
415                    self.microsecond,
416                )
417            }
418            DtUnit::Days => self.add_seconds(value * SECONDS_PER_DAY),
419            DtUnit::Hours => self.add_seconds(value * SECONDS_PER_HOUR),
420            DtUnit::Minutes => self.add_seconds(value * SECONDS_PER_MINUTE),
421            DtUnit::Seconds => self.add_seconds(value),
422            DtUnit::MicroSeconds => self.add_seconds(value / MICROSECONDS_PER_SECOND),
423        }
424    }
425
426    /// Add a (possibly fractional) number of seconds via the epoch round-trip.
427    fn add_seconds(self, seconds: f64) -> Self {
428        DateTime::from_epoch_seconds(self.to_epoch_seconds() + seconds)
429    }
430}
431
432/// The generic nice-number rounding (silx `ticklayout.niceNumGeneric`).
433///
434/// `nice_fractions` is the unit's allowed step list; its last element is the
435/// base of the logarithm. When `is_round` is set, each comparison threshold
436/// (except the last) is the average of adjacent nice fractions, so values round
437/// to the nearer step instead of always rounding up.
438fn nice_num_generic(value: f64, nice_fractions: &[f64], is_round: bool) -> f64 {
439    if value == 0.0 {
440        return value;
441    }
442
443    // roundFractions: average with the next element when rounding; last stays.
444    let mut round_fractions: Vec<f64> = nice_fractions.to_vec();
445    if is_round {
446        for i in 0..round_fractions.len().saturating_sub(1) {
447            round_fractions[i] = (nice_fractions[i] + nice_fractions[i + 1]) / 2.0;
448        }
449    }
450
451    let highest = *nice_fractions.last().expect("nice_fractions is non-empty");
452    let value = value.abs();
453    let expvalue = (value.ln() / highest.ln()).floor();
454    let frac = value / highest.powf(expvalue);
455
456    for (nice_frac, round_frac) in nice_fractions.iter().zip(round_fractions.iter()) {
457        if frac <= *round_frac {
458            return nice_frac * highest.powf(expvalue);
459        }
460    }
461    // silx asserts unreachable here; clamp to the largest nice fraction.
462    highest * highest.powf(expvalue)
463}
464
465/// Nice value for a date element (silx `niceDateTimeElement`). For years and
466/// months the result is at least 1 and integral (no fractional year/month).
467fn nice_date_time_element(value: f64, unit: DtUnit, is_round: bool) -> f64 {
468    let elem = nice_num_generic(value, unit.nice_values(), is_round);
469    if unit == DtUnit::Years || unit == DtUnit::Months {
470        (elem as i64).max(1) as f64
471    } else {
472        elem
473    }
474}
475
476/// Pick the best tick unit for a duration (silx `bestUnit`).
477///
478/// Returns `(count, unit)` where `count` is the duration expressed in `unit`s
479/// (fractional). The thresholds (and their per-unit factors) match silx exactly
480/// (dtime_ticklayout.py:272-285).
481pub fn best_unit(duration_seconds: f64) -> (f64, DtUnit) {
482    if duration_seconds > SECONDS_PER_YEAR * 3.0 {
483        (duration_seconds / SECONDS_PER_YEAR, DtUnit::Years)
484    } else if duration_seconds > SECONDS_PER_MONTH_AVERAGE * 3.0 {
485        (duration_seconds / SECONDS_PER_MONTH_AVERAGE, DtUnit::Months)
486    } else if duration_seconds > SECONDS_PER_DAY * 2.0 {
487        (duration_seconds / SECONDS_PER_DAY, DtUnit::Days)
488    } else if duration_seconds > SECONDS_PER_HOUR * 2.0 {
489        (duration_seconds / SECONDS_PER_HOUR, DtUnit::Hours)
490    } else if duration_seconds > SECONDS_PER_MINUTE * 2.0 {
491        (duration_seconds / SECONDS_PER_MINUTE, DtUnit::Minutes)
492    } else if duration_seconds > 2.0 {
493        (duration_seconds, DtUnit::Seconds)
494    } else {
495        (
496            duration_seconds * MICROSECONDS_PER_SECOND,
497            DtUnit::MicroSeconds,
498        )
499    }
500}
501
502/// Round a date down to the nearest nice start tick (silx `findStartDate`).
503///
504/// Returns `(start, spacing, unit)`. `n_ticks` is the target tick count.
505fn find_start_date(d_min: DateTime, d_max: DateTime, n_ticks: usize) -> (DateTime, f64, DtUnit) {
506    let min_epoch = d_min.to_epoch_seconds();
507    let max_epoch = d_max.to_epoch_seconds();
508    debug_assert!(max_epoch >= min_epoch, "d_min should come before d_max");
509
510    if min_epoch == max_epoch {
511        // Range smaller than microsecond resolution.
512        return (d_min, 1.0, DtUnit::MicroSeconds);
513    }
514
515    let length_sec = max_epoch - min_epoch;
516    let (length, unit) = best_unit(length_sec);
517    let nice_length = nice_date_time_element(length, unit, false);
518    let nice_spacing = nice_date_time_element(nice_length / n_ticks as f64, unit, true);
519
520    let d_val = d_min.get_element(unit) as f64;
521
522    let nice_val = if unit == DtUnit::Months || unit == DtUnit::Days {
523        ((d_val - 1.0) / nice_spacing).floor() * nice_spacing + 1.0
524    } else {
525        (d_val / nice_spacing).floor() * nice_spacing
526    };
527
528    // silx: dt.MINYEAR == 1. Guard a degenerate year start.
529    let nice_val = if unit == DtUnit::Years && nice_val <= 1.0 {
530        nice_spacing.max(1.0)
531    } else {
532        nice_val
533    };
534
535    let start = d_min.round_to_element(unit);
536    let start = start.set_element(nice_val as i64, unit);
537
538    (start, nice_spacing, unit)
539}
540
541/// Generate tick date-times in `[d_min, d_max)` stepping by `step` `unit`s
542/// (silx `dateRange`). When `include_first_beyond` is set, the first tick at or
543/// past `d_max` is also emitted (silx default for `calcTicks`).
544fn date_range(
545    d_min: DateTime,
546    d_max: DateTime,
547    step: f64,
548    unit: DtUnit,
549    include_first_beyond: bool,
550) -> Vec<DateTime> {
551    // Year/month/microsecond have integral steps of at least 1.
552    let step = if unit == DtUnit::Years || unit == DtUnit::Months || unit == DtUnit::MicroSeconds {
553        step.max(1.0)
554    } else {
555        debug_assert!(step > 0.0, "tickstep is 0");
556        step
557    };
558
559    let max_epoch = d_max.to_epoch_seconds();
560    let mut out = Vec::new();
561    let mut dt = d_min;
562    // Bound the loop defensively: even a degenerate step cannot exceed the
563    // theoretical tick count by much; cap to avoid an infinite loop if the
564    // arithmetic ever fails to advance.
565    let mut guard = 0usize;
566    while dt.to_epoch_seconds() < max_epoch {
567        out.push(dt);
568        let next = dt.add_value(step, unit);
569        if next.to_epoch_seconds() <= dt.to_epoch_seconds() {
570            // No forward progress (out of representable range / zero step):
571            // stop to stay total.
572            dt = next;
573            break;
574        }
575        dt = next;
576        guard += 1;
577        if guard > 1_000_000 {
578            break;
579        }
580    }
581    if include_first_beyond {
582        out.push(dt);
583    }
584    out
585}
586
587/// Compute tick positions for a datetime axis (silx `calcTicks`).
588///
589/// `min`/`max` are epoch seconds (UTC); `n_ticks` is the target tick count (the
590/// actual count may differ). Returns `(ticks, spacing, unit)` where `ticks` are
591/// epoch seconds. The returned ticks always bracket `[min, max]`: the first is
592/// at or below `min` (rounded-down start) and the last is at or beyond `max`
593/// (the `include_first_beyond` tick).
594pub fn calc_ticks(min: f64, max: f64, n_ticks: usize) -> (Vec<f64>, f64, DtUnit) {
595    calc_ticks_tz(min, max, n_ticks, TimeZone::Utc)
596}
597
598/// As [`calc_ticks`] but laying the ticks out in the wall-clock calendar of
599/// `tz` (silx passes the tz-aware `dMin`/`dMax` into `calcTicks`). The endpoints
600/// are decomposed with the zone offset, all the nice-date arithmetic runs in
601/// that wall-clock space (identical to the UTC path), and each tick is converted
602/// back to epoch seconds with the offset. The returned ticks are epoch seconds.
603pub fn calc_ticks_tz(min: f64, max: f64, n_ticks: usize, tz: TimeZone) -> (Vec<f64>, f64, DtUnit) {
604    let n_ticks = n_ticks.max(1);
605    let d_min = DateTime::from_epoch_seconds_tz(min, tz);
606    let d_max = DateTime::from_epoch_seconds_tz(max, tz);
607    let (start, spacing, unit) = find_start_date(d_min, d_max, n_ticks);
608    let dates = date_range(start, d_max, spacing, unit, true);
609    let ticks = dates.iter().map(|d| d.to_epoch_seconds_tz(tz)).collect();
610    (ticks, spacing, unit)
611}
612
613/// As [`calc_ticks`] but derives the target tick count from an axis length in
614/// pixels and a tick density (silx `calcTicksAdaptive`). At least 2 ticks.
615pub fn calc_ticks_adaptive(
616    min: f64,
617    max: f64,
618    axis_length: f64,
619    tick_density: f64,
620) -> (Vec<f64>, f64, DtUnit) {
621    calc_ticks_adaptive_tz(min, max, axis_length, tick_density, TimeZone::Utc)
622}
623
624/// As [`calc_ticks_adaptive`] but laying the ticks out in the wall-clock
625/// calendar of `tz` (silx `calcTicksAdaptive` on tz-aware datetimes).
626pub fn calc_ticks_adaptive_tz(
627    min: f64,
628    max: f64,
629    axis_length: f64,
630    tick_density: f64,
631    tz: TimeZone,
632) -> (Vec<f64>, f64, DtUnit) {
633    let n = (tick_density * axis_length).round() as i64;
634    let n = n.max(2) as usize;
635    calc_ticks_tz(min, max, n, tz)
636}
637
638/// Zero-pad an integer to `width` digits.
639fn pad(value: i64, width: usize) -> String {
640    if value < 0 {
641        format!("-{:0width$}", -value, width = width)
642    } else {
643        format!("{value:0width$}")
644    }
645}
646
647/// Format a single tick (epoch seconds, UTC) for the given `spacing`/`unit`,
648/// mirroring silx `bestFormatString` + `datetime.strftime`.
649///
650/// For [`DtUnit::MicroSeconds`] the silx code additionally strips a common run
651/// of trailing zeros across all labels; that cross-label step needs the whole
652/// tick set, so it is done in [`format_ticks`]. This single-tick helper returns
653/// the raw `%S.%f` form for microseconds.
654pub fn format_tick(epoch: f64, spacing: f64, unit: DtUnit) -> String {
655    format_tick_tz(epoch, spacing, unit, TimeZone::Utc)
656}
657
658/// As [`format_tick`] but rendering the wall-clock label in `tz` (silx formats
659/// the tz-aware tick datetime). The epoch is decomposed with the zone offset
660/// before formatting.
661pub fn format_tick_tz(epoch: f64, spacing: f64, unit: DtUnit, tz: TimeZone) -> String {
662    let d = DateTime::from_epoch_seconds_tz(epoch, tz);
663    let is_small = spacing < 1.0;
664    match unit {
665        DtUnit::Years => {
666            if is_small {
667                // silx uses "%Y-m" here (literal "m"); reproduced faithfully.
668                format!("{}-m", pad(d.year, 4))
669            } else {
670                pad(d.year, 4)
671            }
672        }
673        DtUnit::Months => {
674            if is_small {
675                format!(
676                    "{}-{}-{}",
677                    pad(d.year, 4),
678                    pad(d.month as i64, 2),
679                    pad(d.day as i64, 2)
680                )
681            } else {
682                format!("{}-{}", pad(d.year, 4), pad(d.month as i64, 2))
683            }
684        }
685        DtUnit::Days => {
686            if is_small {
687                format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
688            } else {
689                format!(
690                    "{}-{}-{}",
691                    pad(d.year, 4),
692                    pad(d.month as i64, 2),
693                    pad(d.day as i64, 2)
694                )
695            }
696        }
697        DtUnit::Hours => {
698            format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
699        }
700        DtUnit::Minutes => {
701            if is_small {
702                format!(
703                    "{}:{}:{}",
704                    pad(d.hour as i64, 2),
705                    pad(d.minute as i64, 2),
706                    pad(d.second as i64, 2)
707                )
708            } else {
709                format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
710            }
711        }
712        DtUnit::Seconds => {
713            if is_small {
714                format!(
715                    "{}.{}",
716                    pad(d.second as i64, 2),
717                    pad(d.microsecond as i64, 6)
718                )
719            } else {
720                format!(
721                    "{}:{}:{}",
722                    pad(d.hour as i64, 2),
723                    pad(d.minute as i64, 2),
724                    pad(d.second as i64, 2)
725                )
726            }
727        }
728        DtUnit::MicroSeconds => {
729            format!(
730                "{}.{}",
731                pad(d.second as i64, 2),
732                pad(d.microsecond as i64, 6)
733            )
734        }
735    }
736}
737
738/// Format a whole tick set (silx `formatDatetimes`). For
739/// [`DtUnit::MicroSeconds`] this applies silx's cross-label trailing-zero strip:
740/// it finds the minimum number of trailing `'0'` shared by every label (capped
741/// at 5), drops a leading `'0'` per label, and trims that many trailing chars.
742pub fn format_ticks(ticks: &[f64], spacing: f64, unit: DtUnit) -> Vec<String> {
743    format_ticks_tz(ticks, spacing, unit, TimeZone::Utc)
744}
745
746/// As [`format_ticks`] but rendering the wall-clock labels in `tz`.
747pub fn format_ticks_tz(ticks: &[f64], spacing: f64, unit: DtUnit, tz: TimeZone) -> Vec<String> {
748    if unit != DtUnit::MicroSeconds {
749        return ticks
750            .iter()
751            .map(|&t| format_tick_tz(t, spacing, unit, tz))
752            .collect();
753    }
754
755    let texts: Vec<String> = ticks
756        .iter()
757        .map(|&t| format_tick_tz(t, spacing, unit, tz))
758        .collect();
759    if texts.is_empty() {
760        return texts;
761    }
762
763    let nzeros = texts
764        .iter()
765        .map(|t| t.len() - t.trim_end_matches('0').len())
766        .min()
767        .unwrap_or(0);
768    let trim = nzeros.min(5);
769
770    texts
771        .iter()
772        .map(|text| {
773            let chars: Vec<char> = text.chars().collect();
774            // text[0 if text[0] != '0' else 1 : -min(nzeros, 5)]
775            let start = if chars.first() == Some(&'0') { 1 } else { 0 };
776            // Python slice with negative stop: end index counted from the end.
777            let end = chars.len().saturating_sub(trim);
778            if start >= end {
779                String::new()
780            } else {
781                chars[start..end].iter().collect()
782            }
783        })
784        .collect()
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    fn close(a: f64, b: f64, tol: f64) -> bool {
792        (a - b).abs() <= tol
793    }
794
795    #[test]
796    fn civil_epoch_round_trip_known_dates() {
797        // (epoch seconds, expected civil). Values cross-checked against known
798        // UTC instants.
799        let cases = [
800            (0.0_f64, (1970, 1, 1, 0, 0, 0)),
801            (86_400.0, (1970, 1, 2, 0, 0, 0)),
802            // 2000-02-29 (leap day) 12:30:45 UTC.
803            (951_827_445.0, (2000, 2, 29, 12, 30, 45)),
804            // 2021-01-01 00:00:00 UTC.
805            (1_609_459_200.0, (2021, 1, 1, 0, 0, 0)),
806            // 2024-02-29 (leap day) 23:59:59 UTC.
807            (1_709_251_199.0, (2024, 2, 29, 23, 59, 59)),
808        ];
809        for (epoch, (y, m, d, hh, mm, ss)) in cases {
810            let dt = DateTime::from_epoch_seconds(epoch);
811            assert_eq!(
812                (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
813                (y, m, d, hh, mm, ss),
814                "decompose {epoch}"
815            );
816            // And the round-trip back is exact.
817            assert!(
818                close(dt.to_epoch_seconds(), epoch, 1e-6),
819                "round-trip {epoch}"
820            );
821        }
822    }
823
824    #[test]
825    fn civil_round_trip_before_epoch() {
826        // Negative timestamp: 1969-12-31 23:59:59 UTC.
827        let dt = DateTime::from_epoch_seconds(-1.0);
828        assert_eq!(
829            (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
830            (1969, 12, 31, 23, 59, 59)
831        );
832        assert!(close(dt.to_epoch_seconds(), -1.0, 1e-6));
833    }
834
835    #[test]
836    fn civil_to_epoch_for_leap_day() {
837        // 2000-02-29 is day 11016 from epoch; verify both directions.
838        let days = days_from_civil(2000, 2, 29);
839        assert_eq!(civil_from_days(days), (2000, 2, 29));
840        let dt = DateTime::from_civil(2000, 2, 29, 0, 0, 0, 0);
841        assert_eq!(dt.to_epoch_seconds(), days as f64 * SECONDS_PER_DAY);
842    }
843
844    #[test]
845    fn best_unit_picks_day_for_a_week() {
846        // ~7-day range -> DAYS (between 2 days and 2 months threshold).
847        let (count, unit) = best_unit(7.0 * SECONDS_PER_DAY);
848        assert_eq!(unit, DtUnit::Days);
849        assert!(close(count, 7.0, 1e-9), "count={count}");
850    }
851
852    #[test]
853    fn best_unit_picks_hour_for_six_hours() {
854        // ~6-hour range -> HOURS (between 2 hours and 2 days threshold).
855        let (count, unit) = best_unit(6.0 * SECONDS_PER_HOUR);
856        assert_eq!(unit, DtUnit::Hours);
857        assert!(close(count, 6.0, 1e-9), "count={count}");
858    }
859
860    #[test]
861    fn best_unit_boundary_units() {
862        // Each branch boundary: just over the threshold selects the coarser unit.
863        assert_eq!(best_unit(SECONDS_PER_YEAR * 3.0 + 1.0).1, DtUnit::Years);
864        assert_eq!(
865            best_unit(SECONDS_PER_MONTH_AVERAGE * 3.0 + 1.0).1,
866            DtUnit::Months
867        );
868        assert_eq!(best_unit(SECONDS_PER_DAY * 2.0 + 1.0).1, DtUnit::Days);
869        assert_eq!(best_unit(SECONDS_PER_HOUR * 2.0 + 1.0).1, DtUnit::Hours);
870        assert_eq!(best_unit(SECONDS_PER_MINUTE * 2.0 + 1.0).1, DtUnit::Minutes);
871        assert_eq!(best_unit(2.0 + 0.5).1, DtUnit::Seconds);
872        // <= 2 seconds falls through to microseconds.
873        assert_eq!(best_unit(1.0).1, DtUnit::MicroSeconds);
874        assert_eq!(best_unit(0.0).1, DtUnit::MicroSeconds);
875    }
876
877    #[test]
878    fn calc_ticks_endpoints_bracket_the_range() {
879        // A one-week window starting at 2021-01-04 00:00:00 UTC.
880        let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds();
881        let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds();
882        let (ticks, _spacing, unit) = calc_ticks(min, max, 5);
883        assert_eq!(unit, DtUnit::Days);
884        assert!(ticks.len() >= 2, "ticks={ticks:?}");
885        // First tick at or below min, last tick at or beyond max (brackets range).
886        assert!(ticks[0] <= min + 1e-6, "first {} > min {min}", ticks[0]);
887        assert!(
888            *ticks.last().unwrap() >= max - 1e-6,
889            "last {} < max {max}",
890            ticks.last().unwrap()
891        );
892        // Ticks are strictly increasing.
893        for w in ticks.windows(2) {
894            assert!(w[1] > w[0], "non-increasing: {:?}", w);
895        }
896    }
897
898    #[test]
899    fn calc_ticks_six_hour_window_uses_hours() {
900        let min = DateTime::from_civil(2021, 6, 1, 8, 0, 0, 0).to_epoch_seconds();
901        let max = DateTime::from_civil(2021, 6, 1, 14, 0, 0, 0).to_epoch_seconds();
902        let (ticks, _spacing, unit) = calc_ticks(min, max, 6);
903        assert_eq!(unit, DtUnit::Hours);
904        assert!(ticks[0] <= min + 1e-6);
905        assert!(*ticks.last().unwrap() >= max - 1e-6);
906    }
907
908    #[test]
909    fn calc_ticks_degenerate_range_is_total() {
910        // min == max: silx returns the microsecond fallback; must not loop.
911        let t = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 0).to_epoch_seconds();
912        let (ticks, spacing, unit) = calc_ticks(t, t, 5);
913        assert_eq!(unit, DtUnit::MicroSeconds);
914        assert_eq!(spacing, 1.0);
915        // include_first_beyond emits the single start tick.
916        assert_eq!(ticks.len(), 1);
917        assert!(close(ticks[0], t, 1e-6));
918    }
919
920    #[test]
921    fn nice_num_generic_matches_default_fractions() {
922        // With default-style fractions [1,2,5,10] and isRound=false, frac<=1 -> 1.
923        let v = nice_num_generic(1.0, &[1.0, 2.0, 5.0, 10.0], false);
924        assert!(close(v, 1.0, 1e-12), "v={v}");
925        // 7 rounds up to 10 (frac 7 > 5).
926        let v = nice_num_generic(7.0, &[1.0, 2.0, 5.0, 10.0], false);
927        assert!(close(v, 10.0, 1e-12), "v={v}");
928        // 3 with isRound: thresholds become [1.5, 3.5, 7.5, 10]; 3 <= 3.5 -> 2.
929        let v = nice_num_generic(3.0, &[1.0, 2.0, 5.0, 10.0], true);
930        assert!(close(v, 2.0, 1e-12), "v={v}");
931    }
932
933    #[test]
934    fn nice_date_time_element_floors_years_to_one() {
935        // A sub-1 nice year value is clamped up to 1.
936        let v = nice_date_time_element(0.3, DtUnit::Years, true);
937        assert_eq!(v, 1.0);
938    }
939
940    #[test]
941    fn format_tick_day_unit_is_iso_date() {
942        // Days unit, spacing >= 1 -> "%Y-%m-%d".
943        let t = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
944        assert_eq!(format_tick(t, 1.0, DtUnit::Days), "2021-03-09");
945    }
946
947    #[test]
948    fn format_tick_hours_is_hh_mm() {
949        let t = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
950        assert_eq!(format_tick(t, 1.0, DtUnit::Hours), "13:05");
951    }
952
953    #[test]
954    fn format_ticks_microseconds_strips_shared_trailing_zeros() {
955        // Two ticks at .100000 and .200000 -> shared 5 trailing zeros stripped,
956        // and the leading '0' of the seconds field dropped.
957        let base = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 100_000).to_epoch_seconds();
958        let t2 = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 200_000).to_epoch_seconds();
959        let out = format_ticks(&[base, t2], 0.5, DtUnit::MicroSeconds);
960        // Raw labels: "00.100000", "00.200000". Min trailing zeros = 5 (capped).
961        // Leading '0' dropped -> start at index 1; trim 5 from the end.
962        // "00.100000"[1..len-5] = "0.100000"[.. ] => "0.1" .. let's assert content.
963        assert_eq!(out, vec!["0.1".to_string(), "0.2".to_string()]);
964    }
965
966    #[test]
967    fn time_zone_offset_at_constant_zones_ignore_instant() {
968        // The constant zones return the same offset regardless of the instant.
969        assert_eq!(TimeZone::Utc.offset_at(0.0), 0);
970        assert_eq!(TimeZone::Utc.offset_at(1_700_000_000.0), 0);
971        let jst = TimeZone::FixedOffset {
972            seconds_east: 32400,
973        };
974        assert_eq!(jst.offset_at(0.0), 32400);
975        assert_eq!(jst.offset_at(1_700_000_000.0), 32400);
976        let est = TimeZone::FixedOffset {
977            seconds_east: -18000,
978        };
979        assert_eq!(est.offset_at(0.0), -18000);
980    }
981
982    #[test]
983    fn named_zone_offset_is_dst_aware() {
984        let ny = TimeZone::named("America/New_York").expect("America/New_York in bundled tz db");
985        // Winter -> EST (UTC-05:00); summer -> EDT (UTC-04:00).
986        let winter = DateTime::from_civil(2021, 1, 15, 12, 0, 0, 0).to_epoch_seconds();
987        let summer = DateTime::from_civil(2021, 7, 15, 12, 0, 0, 0).to_epoch_seconds();
988        assert_eq!(ny.offset_at(winter), -18000, "EST should be UTC-5");
989        assert_eq!(ny.offset_at(summer), -14400, "EDT should be UTC-4");
990        // An unknown zone name resolves to None (silx would raise).
991        assert!(TimeZone::named("Not/AZone").is_none());
992    }
993
994    #[test]
995    fn named_zone_decompose_and_round_trips_both_seasons() {
996        let ny = TimeZone::named("America/New_York").unwrap();
997        // 2021-01-15 12:00 UTC == 2021-01-15 07:00 EST.
998        let winter_utc = DateTime::from_civil(2021, 1, 15, 12, 0, 0, 0).to_epoch_seconds();
999        let d = DateTime::from_epoch_seconds_tz(winter_utc, ny);
1000        assert_eq!(
1001            (d.year, d.month, d.day, d.hour, d.minute, d.second),
1002            (2021, 1, 15, 7, 0, 0)
1003        );
1004        assert!(close(d.to_epoch_seconds_tz(ny), winter_utc, 1e-6));
1005        // 2021-07-15 12:00 UTC == 2021-07-15 08:00 EDT.
1006        let summer_utc = DateTime::from_civil(2021, 7, 15, 12, 0, 0, 0).to_epoch_seconds();
1007        let d = DateTime::from_epoch_seconds_tz(summer_utc, ny);
1008        assert_eq!(
1009            (d.year, d.month, d.day, d.hour, d.minute, d.second),
1010            (2021, 7, 15, 8, 0, 0)
1011        );
1012        assert!(close(d.to_epoch_seconds_tz(ny), summer_utc, 1e-6));
1013    }
1014
1015    #[test]
1016    fn calc_ticks_tz_named_zone_handles_dst_transition() {
1017        // A six-day window spanning US spring-forward (2021-03-14 02:00 EST ->
1018        // 03:00 EDT). For n=5 ticks this picks Days unit with a 1-day spacing.
1019        let ny = TimeZone::named("America/New_York").unwrap();
1020        let min = DateTime::from_civil(2021, 3, 11, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1021        let max = DateTime::from_civil(2021, 3, 17, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1022        let (ticks, spacing, unit) = calc_ticks_tz(min, max, 5, ny);
1023        assert_eq!(unit, DtUnit::Days);
1024        assert_eq!(spacing, 1.0, "expected a 1-day spacing for this window");
1025        // Every daily tick is local New York midnight, even across the change.
1026        for &t in &ticks {
1027            let d = DateTime::from_epoch_seconds_tz(t, ny);
1028            assert_eq!(
1029                (d.hour, d.minute, d.second),
1030                (0, 0, 0),
1031                "tick {t} not NY midnight: {d:?}"
1032            );
1033        }
1034        // The spring-forward civil day is only 23 hours of real time: the gap
1035        // between consecutive local midnights straddling the change is 23h,
1036        // which exercises the DST-aware local->UTC inversion on each side
1037        // (03-14 midnight is EST -5, 03-15 midnight is EDT -4).
1038        let mar14 = DateTime::from_civil(2021, 3, 14, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1039        let mar15 = DateTime::from_civil(2021, 3, 15, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1040        assert!(
1041            close(mar15 - mar14, 23.0 * 3600.0, 1e-6),
1042            "spring-forward day should be 23h, got {}s",
1043            mar15 - mar14
1044        );
1045    }
1046
1047    #[test]
1048    fn from_to_epoch_tz_applies_offset_and_round_trips() {
1049        // epoch 0 == 1970-01-01 00:00:00 UTC. In UTC+09:00 the wall clock reads
1050        // 09:00 the same day; in UTC-05:00 it reads 19:00 the previous day.
1051        let jst = TimeZone::FixedOffset {
1052            seconds_east: 32400,
1053        };
1054        let est = TimeZone::FixedOffset {
1055            seconds_east: -18000,
1056        };
1057
1058        let d = DateTime::from_epoch_seconds_tz(0.0, jst);
1059        assert_eq!(
1060            (d.year, d.month, d.day, d.hour, d.minute, d.second),
1061            (1970, 1, 1, 9, 0, 0)
1062        );
1063        // Round-trip back to the original epoch.
1064        assert!(close(d.to_epoch_seconds_tz(jst), 0.0, 1e-6));
1065
1066        let d = DateTime::from_epoch_seconds_tz(0.0, est);
1067        assert_eq!(
1068            (d.year, d.month, d.day, d.hour, d.minute, d.second),
1069            (1969, 12, 31, 19, 0, 0)
1070        );
1071        assert!(close(d.to_epoch_seconds_tz(est), 0.0, 1e-6));
1072
1073        // UTC is the identity case (matches the non-tz helpers).
1074        let d = DateTime::from_epoch_seconds_tz(86_400.0, TimeZone::Utc);
1075        assert_eq!(d, DateTime::from_epoch_seconds(86_400.0));
1076        assert!(close(d.to_epoch_seconds_tz(TimeZone::Utc), 86_400.0, 1e-6));
1077    }
1078
1079    #[test]
1080    fn format_tick_tz_renders_wall_clock_in_zone() {
1081        // 2021-03-09 13:05:00 UTC. Hours unit -> "%H:%M" of the zone wall clock.
1082        let epoch = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
1083        assert_eq!(
1084            format_tick_tz(epoch, 1.0, DtUnit::Hours, TimeZone::Utc),
1085            "13:05"
1086        );
1087        assert_eq!(
1088            format_tick_tz(
1089                epoch,
1090                1.0,
1091                DtUnit::Hours,
1092                TimeZone::FixedOffset {
1093                    seconds_east: 32400
1094                }
1095            ),
1096            "22:05"
1097        );
1098        // UTC-05:00 rolls back across midnight to the previous calendar day.
1099        assert_eq!(
1100            format_tick_tz(
1101                DateTime::from_civil(2021, 3, 9, 2, 5, 0, 0).to_epoch_seconds(),
1102                1.0,
1103                DtUnit::Hours,
1104                TimeZone::FixedOffset {
1105                    seconds_east: -18000
1106                }
1107            ),
1108            "21:05"
1109        );
1110    }
1111
1112    #[test]
1113    fn calc_ticks_tz_utc_matches_legacy() {
1114        let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds();
1115        let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds();
1116        let (a_ticks, a_spacing, a_unit) = calc_ticks(min, max, 5);
1117        let (b_ticks, b_spacing, b_unit) = calc_ticks_tz(min, max, 5, TimeZone::Utc);
1118        assert_eq!(a_ticks, b_ticks);
1119        assert_eq!(a_spacing, b_spacing);
1120        assert_eq!(a_unit, b_unit);
1121    }
1122
1123    #[test]
1124    fn calc_ticks_tz_daily_ticks_land_on_zone_midnight() {
1125        // A one-week window whose endpoints are local midnight in UTC+09:00.
1126        let jst = TimeZone::FixedOffset {
1127            seconds_east: 32400,
1128        };
1129        let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds_tz(jst);
1130        let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds_tz(jst);
1131        let (ticks, _spacing, unit) = calc_ticks_tz(min, max, 5, jst);
1132        assert_eq!(unit, DtUnit::Days);
1133        assert!(ticks.len() >= 2, "ticks={ticks:?}");
1134        // Every daily tick is exactly local midnight in the zone.
1135        for &t in &ticks {
1136            let d = DateTime::from_epoch_seconds_tz(t, jst);
1137            assert_eq!(
1138                (d.hour, d.minute, d.second),
1139                (0, 0, 0),
1140                "tick {t} not at zone midnight: {d:?}"
1141            );
1142        }
1143        // The first label is the zone-local date, and the ticks bracket [min,max].
1144        let labels = format_ticks_tz(&ticks, _spacing, unit, jst);
1145        assert_eq!(labels[0], "2021-01-04");
1146        assert!(ticks[0] <= min + 1e-6);
1147        assert!(*ticks.last().unwrap() >= max - 1e-6);
1148        // The offset really moved the ticks: under UTC the same epochs lay out at
1149        // a different set of positions (their wall clock is 15:00 the prior day).
1150        let (utc_ticks, _, _) = calc_ticks_tz(min, max, 5, TimeZone::Utc);
1151        assert_ne!(ticks, utc_ticks);
1152    }
1153}