deep_time/time_range.rs
1use crate::{Dt, Scale};
2
3/// Builder type that enables the ergonomic `start.every(step)` syntax.
4///
5/// This struct is created by [`Dt::every`] and is used to
6/// construct a [`TimeRange`] via either `.until(end)` (inclusive) or
7/// `.up_to(end)` (exclusive).
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[cfg_attr(feature = "js", derive(tsify::Tsify))]
10#[derive(Clone, Debug)]
11pub struct Every {
12 pub(crate) start: Dt,
13 pub(crate) step: Dt,
14}
15
16impl Dt {
17 /// Starts building an evenly-spaced time range.
18 ///
19 /// This method returns an [`Every`] builder that can be chained with
20 /// `.until(end)` or `.up_to(end)` to create a [`TimeRange`] iterator.
21 ///
22 /// ## Example
23 ///
24 /// ```
25 /// use deep_time::{Dt, Scale};
26 ///
27 /// let start = Dt::from_ymd(2000, 1, 1);
28 /// let end = Dt::from_ymd(2000, 1, 2);
29 /// let step = Dt::from_hr(1, Scale::TAI);
30 ///
31 /// for timestamp in start.every(step).to_including(end) {
32 /// println!("{:?}", timestamp.to_ymdhms(Scale::TAI));
33 /// }
34 /// ```
35 #[inline]
36 pub const fn every(self, step: Dt) -> Every {
37 Every { start: self, step }
38 }
39
40 /// Creates an **exclusive** evenly-spaced range from `self` to `end`.
41 ///
42 /// Equivalent to `self.every(step).up_to(end)`.
43 #[inline]
44 pub const fn range(self, end: Dt, step: Dt) -> TimeRange {
45 TimeRange::exclusive(self, end, step)
46 }
47
48 /// Creates a range stepping by whole seconds.
49 #[inline]
50 pub const fn every_sec(self) -> Every {
51 self.every(Dt::from_sec(1, Scale::TAI))
52 }
53
54 /// Creates a range stepping by whole minutes.
55 #[inline]
56 pub const fn every_min(self) -> Every {
57 self.every(Dt::from_min(1, Scale::TAI))
58 }
59
60 /// Creates a range stepping by whole hours.
61 #[inline]
62 pub const fn every_hr(self) -> Every {
63 self.every(Dt::from_hr(1, Scale::TAI))
64 }
65
66 /// Creates a range stepping by whole days.
67 #[inline]
68 pub const fn every_day(self) -> Every {
69 self.every(Dt::from_hr(24, Scale::TAI))
70 }
71
72 /// Returns the next `n` points **after** `self` (exclusive of `self`)
73 /// at the given step.
74 ///
75 /// This is a convenient way to get future points without including the start.
76 #[inline]
77 pub fn next_n(self, n: usize, step: Dt) -> impl ExactSizeIterator<Item = Dt> {
78 (self + step).for_n_steps(n, step)
79 }
80
81 /// Returns an iterator yielding exactly `n` evenly spaced points
82 /// starting from `self`.
83 ///
84 /// This is a convenient one-liner for the common "next N steps" pattern.
85 #[inline]
86 pub fn for_n_steps(self, n: usize, step: Dt) -> impl ExactSizeIterator<Item = Dt> {
87 let end = self + step * (n as i64);
88 TimeRange::exclusive(self, end, step).take(n)
89 }
90}
91
92impl Every {
93 /// Creates an **inclusive** time range (`start ... end`).
94 ///
95 /// The resulting iterator will yield `end` as the final element
96 /// (provided `end` is reachable from `start` with the given step).
97 #[inline]
98 pub fn to_including(self, end: Dt) -> TimeRange {
99 TimeRange::new(self.start, end, self.step, true)
100 }
101
102 /// Creates an **exclusive** time range (`start ... end`).
103 ///
104 /// The resulting iterator will **not** yield `end`.
105 #[inline]
106 pub fn to_excluding(self, end: Dt) -> TimeRange {
107 TimeRange::new(self.start, end, self.step, false)
108 }
109}
110
111/// An iterator over evenly spaced [`Dt`] values.
112///
113/// `TimeRange` is the time-domain equivalent of `std::iter::StepBy` or
114/// NumPy's `linspace` / `arange`. It supports both forward and backward
115/// iteration and implements [`ExactSizeIterator`].
116///
117/// ## Construction
118///
119/// ```
120/// use deep_time::{Dt, Scale, TimeRange};
121///
122/// let start = Dt::from_ymd(2000, 1, 1);
123/// let end = Dt::from_ymd(2000, 1, 2);
124/// let step = Dt::from_hr(1, Scale::TAI);
125///
126/// for timestamp in start.every(step).to_including(end) {
127/// println!("{:?}", timestamp.to_ymdhms(Scale::TAI));
128/// }
129///
130/// // Or use the explicit constructors:
131/// TimeRange::inclusive(start, end, step);
132/// TimeRange::exclusive(start, end, step);
133/// ```
134///
135/// ## Iteration Behavior
136///
137/// - Zero step is handled gracefully (yields at most one element).
138/// - Negative steps are supported for reverse iteration.
139/// - The iterator is **lazy** and evaluates in constant time per step.
140/// - Implements [`DoubleEndedIterator`] and [`ExactSizeIterator`].
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142#[cfg_attr(feature = "js", derive(tsify::Tsify))]
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct TimeRange {
145 pub(crate) start: Dt,
146 pub(crate) current: Dt,
147 pub(crate) end: Dt,
148 pub(crate) step: Dt,
149 pub(crate) inclusive: bool,
150 pub(crate) finished: bool,
151}
152
153impl TimeRange {
154 /// Creates an **inclusive** evenly-spaced time range.
155 ///
156 /// The iterator will yield `end` if it is exactly reachable.
157 #[inline]
158 pub const fn inclusive(start: Dt, end: Dt, step: Dt) -> Self {
159 Self::new(start, end, step, true)
160 }
161
162 /// Creates an **exclusive** evenly-spaced time range.
163 ///
164 /// The iterator will **not** yield `end`.
165 #[inline]
166 pub const fn exclusive(start: Dt, end: Dt, step: Dt) -> Self {
167 Self::new(start, end, step, false)
168 }
169
170 /// Internal constructor.
171 #[inline]
172 pub const fn new(start: Dt, end: Dt, step: Dt, inclusive: bool) -> Self {
173 Self {
174 start,
175 current: start,
176 end,
177 step,
178 inclusive,
179 finished: false,
180 }
181 }
182}
183
184impl Iterator for TimeRange {
185 type Item = Dt;
186
187 /// Advances the iterator and returns the next [`Dt`].
188 ///
189 /// Returns `None` once the range has been exhausted.
190 fn next(&mut self) -> Option<Self::Item> {
191 if self.finished {
192 return None;
193 }
194
195 if self.step.is_zero() {
196 self.finished = true;
197 if self.start == self.end && self.inclusive {
198 return Some(self.start);
199 }
200 return None;
201 }
202
203 let item = self.current;
204
205 let to_end = self.current.to_diff_raw(self.end);
206 let step_positive = self.step.is_positive();
207
208 let beyond_end = if step_positive {
209 to_end > Dt::ZERO
210 } else {
211 to_end < Dt::ZERO
212 };
213
214 if beyond_end {
215 self.finished = true;
216 return None;
217 }
218
219 // Exclusive ranges must not yield `end` even when it is exactly reachable
220 if !self.inclusive && self.current == self.end {
221 self.finished = true;
222 return None;
223 }
224
225 self.current += self.step;
226 Some(item)
227 }
228
229 fn size_hint(&self) -> (usize, Option<usize>) {
230 let len = self.len();
231 (len, Some(len))
232 }
233}
234
235impl DoubleEndedIterator for TimeRange {
236 /// Returns the next element from the back of the range.
237 ///
238 /// This allows `TimeRange` to be used with `.rev()` and in
239 /// double-ended iteration contexts.
240 fn next_back(&mut self) -> Option<Self::Item> {
241 if self.finished {
242 return None;
243 }
244
245 let mut rev = *self;
246 rev.step = rev.step.neg();
247
248 let item = rev.next();
249
250 if item.is_some() {
251 self.current = rev.current;
252 }
253
254 item
255 }
256}
257
258impl ExactSizeIterator for TimeRange {
259 /// Returns the exact number of elements this iterator will yield.
260 ///
261 /// This is computed in constant time without iterating.
262 fn len(&self) -> usize {
263 if self.finished {
264 return 0;
265 }
266
267 if self.step.is_zero() {
268 return if self.current == self.end && self.inclusive {
269 1
270 } else {
271 0
272 };
273 }
274
275 // Mirror the yield decision from next()
276 let to_end = self.current.to_diff_raw(self.end);
277 let step_positive = self.step.is_positive();
278
279 let beyond_end = if step_positive {
280 to_end > Dt::ZERO
281 } else {
282 to_end < Dt::ZERO
283 };
284
285 if beyond_end {
286 return 0;
287 }
288
289 if !self.inclusive && self.current == self.end {
290 return 0;
291 }
292
293 // current is yieldable → compute remaining points
294 let diff = self.end.to_diff_raw(self.current);
295 let intervals = diff.abs_div_floor(self.step);
296
297 if self.inclusive {
298 intervals.saturating_add(1)
299 } else {
300 // For exclusive:
301 // - If we would land exactly on `end` after `intervals` steps → exclude it
302 // - Otherwise include the extra point
303 if intervals == 0 {
304 1
305 } else {
306 let reached = self.current + (self.step * (intervals as i64));
307 if reached == self.end {
308 intervals
309 } else {
310 intervals.saturating_add(1)
311 }
312 }
313 }
314 }
315}