Skip to main content

datasynth_core/distributions/
temporal.rs

1//! Temporal distribution samplers for realistic posting patterns.
2//!
3//! Implements seasonality, working hour patterns, and period-end spikes
4//! commonly observed in enterprise accounting systems.
5
6use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use serde::{Deserialize, Serialize};
10
11use super::holidays::HolidayCalendar;
12use super::period_end::PeriodEndDynamics;
13use super::seasonality::IndustrySeasonality;
14
15/// Configuration for seasonality patterns.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SeasonalityConfig {
18    /// Enable month-end volume spikes
19    pub month_end_spike: bool,
20    /// Month-end spike multiplier (e.g., 2.5 = 2.5x normal volume)
21    pub month_end_multiplier: f64,
22    /// Days before month-end to start spike
23    pub month_end_lead_days: u32,
24
25    /// Enable quarter-end spikes
26    pub quarter_end_spike: bool,
27    /// Quarter-end spike multiplier
28    pub quarter_end_multiplier: f64,
29
30    /// Enable year-end spikes
31    pub year_end_spike: bool,
32    /// Year-end spike multiplier
33    pub year_end_multiplier: f64,
34
35    /// Activity level on weekends (0.0 = no activity, 1.0 = normal)
36    pub weekend_activity: f64,
37    /// Activity level on holidays
38    pub holiday_activity: f64,
39
40    /// Enable day-of-week patterns (Monday catch-up, Friday slowdown)
41    pub day_of_week_patterns: bool,
42    /// Monday activity multiplier (catch-up from weekend)
43    pub monday_multiplier: f64,
44    /// Tuesday activity multiplier
45    pub tuesday_multiplier: f64,
46    /// Wednesday activity multiplier
47    pub wednesday_multiplier: f64,
48    /// Thursday activity multiplier
49    pub thursday_multiplier: f64,
50    /// Friday activity multiplier (early departures)
51    pub friday_multiplier: f64,
52}
53
54impl Default for SeasonalityConfig {
55    fn default() -> Self {
56        Self {
57            month_end_spike: true,
58            month_end_multiplier: 2.5,
59            month_end_lead_days: 5,
60            quarter_end_spike: true,
61            quarter_end_multiplier: 4.0,
62            year_end_spike: true,
63            year_end_multiplier: 6.0,
64            weekend_activity: 0.1,
65            holiday_activity: 0.05,
66            // Day-of-week patterns: humans work differently across the week
67            day_of_week_patterns: true,
68            monday_multiplier: 1.3,    // Catch-up from weekend backlog
69            tuesday_multiplier: 1.1,   // Still catching up
70            wednesday_multiplier: 1.0, // Midweek normal
71            thursday_multiplier: 1.0,  // Midweek normal
72            friday_multiplier: 0.85,   // Early departures, winding down
73        }
74    }
75}
76
77/// Configuration for working hours pattern.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WorkingHoursConfig {
80    /// Start of working day (hour, 0-23)
81    pub day_start: u8,
82    /// End of working day (hour, 0-23)
83    pub day_end: u8,
84    /// Peak hours during the day
85    pub peak_hours: Vec<u8>,
86    /// Weight for peak hours (multiplier)
87    pub peak_weight: f64,
88    /// Probability of after-hours posting
89    pub after_hours_probability: f64,
90}
91
92impl Default for WorkingHoursConfig {
93    fn default() -> Self {
94        Self {
95            day_start: 8,
96            day_end: 18,
97            peak_hours: vec![9, 10, 11, 14, 15, 16],
98            peak_weight: 1.5,
99            after_hours_probability: 0.05,
100        }
101    }
102}
103
104/// Configuration for intra-day posting patterns.
105///
106/// Defines segments of the business day with different activity multipliers,
107/// allowing for realistic modeling of morning spikes, lunch dips, and end-of-day rushes.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct IntraDayPatterns {
110    /// Whether intra-day patterns are enabled.
111    pub enabled: bool,
112    /// Time segments with activity multipliers.
113    pub segments: Vec<IntraDaySegment>,
114}
115
116impl Default for IntraDayPatterns {
117    fn default() -> Self {
118        Self {
119            enabled: true,
120            segments: vec![
121                IntraDaySegment {
122                    name: "morning_spike".to_string(),
123                    start: NaiveTime::from_hms_opt(8, 30, 0).expect("valid date/time components"),
124                    end: NaiveTime::from_hms_opt(10, 0, 0).expect("valid date/time components"),
125                    multiplier: 1.8,
126                    posting_type: PostingType::Both,
127                },
128                IntraDaySegment {
129                    name: "mid_morning".to_string(),
130                    start: NaiveTime::from_hms_opt(10, 0, 0).expect("valid date/time components"),
131                    end: NaiveTime::from_hms_opt(12, 0, 0).expect("valid date/time components"),
132                    multiplier: 1.2,
133                    posting_type: PostingType::Both,
134                },
135                IntraDaySegment {
136                    name: "lunch_dip".to_string(),
137                    start: NaiveTime::from_hms_opt(12, 0, 0).expect("valid date/time components"),
138                    end: NaiveTime::from_hms_opt(13, 30, 0).expect("valid date/time components"),
139                    multiplier: 0.4,
140                    posting_type: PostingType::Human,
141                },
142                IntraDaySegment {
143                    name: "afternoon".to_string(),
144                    start: NaiveTime::from_hms_opt(13, 30, 0).expect("valid date/time components"),
145                    end: NaiveTime::from_hms_opt(16, 0, 0).expect("valid date/time components"),
146                    multiplier: 1.1,
147                    posting_type: PostingType::Both,
148                },
149                IntraDaySegment {
150                    name: "eod_rush".to_string(),
151                    start: NaiveTime::from_hms_opt(16, 0, 0).expect("valid date/time components"),
152                    end: NaiveTime::from_hms_opt(17, 30, 0).expect("valid date/time components"),
153                    multiplier: 1.5,
154                    posting_type: PostingType::Both,
155                },
156            ],
157        }
158    }
159}
160
161impl IntraDayPatterns {
162    /// Creates intra-day patterns with no segments (disabled).
163    pub fn disabled() -> Self {
164        Self {
165            enabled: false,
166            segments: Vec::new(),
167        }
168    }
169
170    /// Creates patterns with custom segments.
171    pub fn with_segments(segments: Vec<IntraDaySegment>) -> Self {
172        Self {
173            enabled: true,
174            segments,
175        }
176    }
177
178    /// Gets the multiplier for a given time based on posting type.
179    pub fn get_multiplier(&self, time: NaiveTime, is_human: bool) -> f64 {
180        if !self.enabled {
181            return 1.0;
182        }
183
184        for segment in &self.segments {
185            if time >= segment.start && time < segment.end {
186                // Check if this segment applies to the posting type
187                let applies = match segment.posting_type {
188                    PostingType::Human => is_human,
189                    PostingType::System => !is_human,
190                    PostingType::Both => true,
191                };
192                if applies {
193                    return segment.multiplier;
194                }
195            }
196        }
197
198        1.0 // Default multiplier if no segment matches
199    }
200}
201
202/// A segment of the business day with specific activity patterns.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct IntraDaySegment {
205    /// Name of the segment (e.g., "morning_spike", "lunch_dip").
206    pub name: String,
207    /// Start time of the segment.
208    pub start: NaiveTime,
209    /// End time of the segment.
210    pub end: NaiveTime,
211    /// Activity multiplier for this segment (1.0 = normal).
212    pub multiplier: f64,
213    /// Type of postings this segment applies to.
214    pub posting_type: PostingType,
215}
216
217/// Type of posting for intra-day pattern matching.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum PostingType {
221    /// Human/manual postings only.
222    Human,
223    /// System/automated postings only.
224    System,
225    /// Both human and system postings.
226    Both,
227}
228
229/// Sampler for temporal patterns in transaction generation.
230pub struct TemporalSampler {
231    rng: ChaCha8Rng,
232    seasonality_config: SeasonalityConfig,
233    working_hours_config: WorkingHoursConfig,
234    /// List of holiday dates (legacy)
235    holidays: Vec<NaiveDate>,
236    /// Industry-specific seasonality patterns (optional).
237    industry_seasonality: Option<IndustrySeasonality>,
238    /// Regional holiday calendar (optional).
239    holiday_calendar: Option<HolidayCalendar>,
240    /// Period-end dynamics for decay curves (optional).
241    period_end_dynamics: Option<PeriodEndDynamics>,
242    /// Whether to use period-end dynamics instead of legacy flat multipliers.
243    use_period_end_dynamics: bool,
244    /// Intra-day patterns for time-of-day activity variation.
245    intra_day_patterns: Option<IntraDayPatterns>,
246}
247
248impl TemporalSampler {
249    /// Create a new temporal sampler.
250    pub fn new(seed: u64) -> Self {
251        Self::with_config(
252            seed,
253            SeasonalityConfig::default(),
254            WorkingHoursConfig::default(),
255            Vec::new(),
256        )
257    }
258
259    /// Create a temporal sampler with custom configuration.
260    pub fn with_config(
261        seed: u64,
262        seasonality_config: SeasonalityConfig,
263        working_hours_config: WorkingHoursConfig,
264        holidays: Vec<NaiveDate>,
265    ) -> Self {
266        Self {
267            rng: ChaCha8Rng::seed_from_u64(seed),
268            seasonality_config,
269            working_hours_config,
270            holidays,
271            industry_seasonality: None,
272            holiday_calendar: None,
273            period_end_dynamics: None,
274            use_period_end_dynamics: false,
275            intra_day_patterns: None,
276        }
277    }
278
279    /// Create a temporal sampler with full enhanced configuration.
280    #[allow(clippy::too_many_arguments)]
281    pub fn with_full_config(
282        seed: u64,
283        seasonality_config: SeasonalityConfig,
284        working_hours_config: WorkingHoursConfig,
285        holidays: Vec<NaiveDate>,
286        industry_seasonality: Option<IndustrySeasonality>,
287        holiday_calendar: Option<HolidayCalendar>,
288    ) -> Self {
289        Self {
290            rng: ChaCha8Rng::seed_from_u64(seed),
291            seasonality_config,
292            working_hours_config,
293            holidays,
294            industry_seasonality,
295            holiday_calendar,
296            period_end_dynamics: None,
297            use_period_end_dynamics: false,
298            intra_day_patterns: None,
299        }
300    }
301
302    /// Create a temporal sampler with period-end dynamics.
303    #[allow(clippy::too_many_arguments)]
304    pub fn with_period_end_dynamics(
305        seed: u64,
306        seasonality_config: SeasonalityConfig,
307        working_hours_config: WorkingHoursConfig,
308        holidays: Vec<NaiveDate>,
309        industry_seasonality: Option<IndustrySeasonality>,
310        holiday_calendar: Option<HolidayCalendar>,
311        period_end_dynamics: PeriodEndDynamics,
312    ) -> Self {
313        Self {
314            rng: ChaCha8Rng::seed_from_u64(seed),
315            seasonality_config,
316            working_hours_config,
317            holidays,
318            industry_seasonality,
319            holiday_calendar,
320            period_end_dynamics: Some(period_end_dynamics),
321            use_period_end_dynamics: true,
322            intra_day_patterns: None,
323        }
324    }
325
326    /// Sets the intra-day patterns for time-of-day activity variation.
327    pub fn set_intra_day_patterns(&mut self, patterns: IntraDayPatterns) {
328        self.intra_day_patterns = Some(patterns);
329    }
330
331    /// Gets the intra-day multiplier for a given time.
332    pub fn get_intra_day_multiplier(&self, time: NaiveTime, is_human: bool) -> f64 {
333        self.intra_day_patterns
334            .as_ref()
335            .map(|p| p.get_multiplier(time, is_human))
336            .unwrap_or(1.0)
337    }
338
339    /// Set industry-specific seasonality.
340    pub fn with_industry_seasonality(mut self, seasonality: IndustrySeasonality) -> Self {
341        self.industry_seasonality = Some(seasonality);
342        self
343    }
344
345    /// Set regional holiday calendar.
346    pub fn with_holiday_calendar(mut self, calendar: HolidayCalendar) -> Self {
347        self.holiday_calendar = Some(calendar);
348        self
349    }
350
351    /// Set industry seasonality (mutable reference version).
352    pub fn set_industry_seasonality(&mut self, seasonality: IndustrySeasonality) {
353        self.industry_seasonality = Some(seasonality);
354    }
355
356    /// Set holiday calendar (mutable reference version).
357    pub fn set_holiday_calendar(&mut self, calendar: HolidayCalendar) {
358        self.holiday_calendar = Some(calendar);
359    }
360
361    /// Set period-end dynamics.
362    pub fn with_period_end(mut self, dynamics: PeriodEndDynamics) -> Self {
363        self.period_end_dynamics = Some(dynamics);
364        self.use_period_end_dynamics = true;
365        self
366    }
367
368    /// Set period-end dynamics (mutable reference version).
369    pub fn set_period_end_dynamics(&mut self, dynamics: PeriodEndDynamics) {
370        self.period_end_dynamics = Some(dynamics);
371        self.use_period_end_dynamics = true;
372    }
373
374    /// Get the period-end dynamics if set.
375    pub fn period_end_dynamics(&self) -> Option<&PeriodEndDynamics> {
376        self.period_end_dynamics.as_ref()
377    }
378
379    /// Enable or disable period-end dynamics usage.
380    pub fn set_use_period_end_dynamics(&mut self, enabled: bool) {
381        self.use_period_end_dynamics = enabled;
382    }
383
384    /// Get the industry seasonality if set.
385    pub fn industry_seasonality(&self) -> Option<&IndustrySeasonality> {
386        self.industry_seasonality.as_ref()
387    }
388
389    /// Get the holiday calendar if set.
390    pub fn holiday_calendar(&self) -> Option<&HolidayCalendar> {
391        self.holiday_calendar.as_ref()
392    }
393
394    /// Generate US federal holidays for a given year.
395    pub fn generate_us_holidays(year: i32) -> Vec<NaiveDate> {
396        let mut holidays = Vec::new();
397
398        // New Year's Day
399        holidays.push(NaiveDate::from_ymd_opt(year, 1, 1).expect("valid date/time components"));
400        // Independence Day
401        holidays.push(NaiveDate::from_ymd_opt(year, 7, 4).expect("valid date/time components"));
402        // Christmas
403        holidays.push(NaiveDate::from_ymd_opt(year, 12, 25).expect("valid date/time components"));
404        // Thanksgiving (4th Thursday of November)
405        let first_thursday = (1..=7)
406            .map(|d| NaiveDate::from_ymd_opt(year, 11, d).expect("valid date/time components"))
407            .find(|d| d.weekday() == Weekday::Thu)
408            .expect("valid date/time components");
409        let thanksgiving = first_thursday + Duration::weeks(3);
410        holidays.push(thanksgiving);
411
412        holidays
413    }
414
415    /// Check if a date is a weekend.
416    pub fn is_weekend(&self, date: NaiveDate) -> bool {
417        matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
418    }
419
420    /// Get the day-of-week activity multiplier.
421    ///
422    /// Returns a multiplier based on the day of the week:
423    /// - Monday: Higher activity (catch-up from weekend)
424    /// - Tuesday: Slightly elevated
425    /// - Wednesday/Thursday: Normal
426    /// - Friday: Reduced (early departures, winding down)
427    /// - Saturday/Sunday: Uses weekend_activity setting
428    pub fn get_day_of_week_multiplier(&self, date: NaiveDate) -> f64 {
429        if !self.seasonality_config.day_of_week_patterns {
430            return 1.0;
431        }
432
433        match date.weekday() {
434            Weekday::Mon => self.seasonality_config.monday_multiplier,
435            Weekday::Tue => self.seasonality_config.tuesday_multiplier,
436            Weekday::Wed => self.seasonality_config.wednesday_multiplier,
437            Weekday::Thu => self.seasonality_config.thursday_multiplier,
438            Weekday::Fri => self.seasonality_config.friday_multiplier,
439            Weekday::Sat | Weekday::Sun => 1.0, // Weekend activity handled separately
440        }
441    }
442
443    /// Check if a date is a holiday.
444    pub fn is_holiday(&self, date: NaiveDate) -> bool {
445        // Check legacy holidays list
446        if self.holidays.contains(&date) {
447            return true;
448        }
449
450        // Check holiday calendar if available
451        if let Some(ref calendar) = self.holiday_calendar {
452            if calendar.is_holiday(date) {
453                return true;
454            }
455        }
456
457        false
458    }
459
460    /// Get the holiday activity multiplier for a date.
461    fn get_holiday_multiplier(&self, date: NaiveDate) -> f64 {
462        // Check holiday calendar first (more accurate)
463        if let Some(ref calendar) = self.holiday_calendar {
464            let mult = calendar.get_multiplier(date);
465            if mult < 1.0 {
466                return mult;
467            }
468        }
469
470        // Fall back to legacy holidays with default multiplier
471        if self.holidays.contains(&date) {
472            return self.seasonality_config.holiday_activity;
473        }
474
475        1.0
476    }
477
478    /// Check if a date is month-end (last N days of month).
479    pub fn is_month_end(&self, date: NaiveDate) -> bool {
480        let last_day = Self::last_day_of_month(date);
481        let days_until_end = (last_day - date).num_days();
482        days_until_end >= 0 && days_until_end < self.seasonality_config.month_end_lead_days as i64
483    }
484
485    /// Check if a date is quarter-end.
486    pub fn is_quarter_end(&self, date: NaiveDate) -> bool {
487        let month = date.month();
488        let is_quarter_end_month = matches!(month, 3 | 6 | 9 | 12);
489        is_quarter_end_month && self.is_month_end(date)
490    }
491
492    /// Check if a date is year-end.
493    pub fn is_year_end(&self, date: NaiveDate) -> bool {
494        date.month() == 12 && self.is_month_end(date)
495    }
496
497    /// Get the last day of the month for a given date.
498    pub fn last_day_of_month(date: NaiveDate) -> NaiveDate {
499        let year = date.year();
500        let month = date.month();
501
502        if month == 12 {
503            NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date/time components")
504                - Duration::days(1)
505        } else {
506            NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date/time components")
507                - Duration::days(1)
508        }
509    }
510
511    /// Get the activity multiplier for a specific date.
512    ///
513    /// Combines:
514    /// - Base seasonality (month-end, quarter-end, year-end spikes)
515    /// - Day-of-week patterns (Monday catch-up, Friday slowdown)
516    /// - Weekend activity reduction
517    /// - Holiday activity reduction (from calendar or legacy list)
518    /// - Industry-specific seasonality (if configured)
519    /// - Period-end dynamics (if configured, replaces legacy flat multipliers)
520    pub fn get_date_multiplier(&self, date: NaiveDate) -> f64 {
521        let mut multiplier = 1.0;
522
523        // Weekend reduction
524        if self.is_weekend(date) {
525            multiplier *= self.seasonality_config.weekend_activity;
526        } else {
527            // Day-of-week patterns (only for weekdays)
528            multiplier *= self.get_day_of_week_multiplier(date);
529        }
530
531        // Holiday reduction (using enhanced calendar if available)
532        let holiday_mult = self.get_holiday_multiplier(date);
533        if holiday_mult < 1.0 {
534            multiplier *= holiday_mult;
535        }
536
537        // Period-end spikes - use dynamics if available, otherwise legacy flat multipliers
538        if self.use_period_end_dynamics {
539            if let Some(ref dynamics) = self.period_end_dynamics {
540                let period_mult = dynamics.get_multiplier_for_date(date);
541                multiplier *= period_mult;
542            }
543        } else {
544            // Legacy flat multipliers (take the highest applicable)
545            if self.seasonality_config.year_end_spike && self.is_year_end(date) {
546                multiplier *= self.seasonality_config.year_end_multiplier;
547            } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
548                multiplier *= self.seasonality_config.quarter_end_multiplier;
549            } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
550                multiplier *= self.seasonality_config.month_end_multiplier;
551            }
552        }
553
554        // Industry-specific seasonality
555        if let Some(ref industry) = self.industry_seasonality {
556            let industry_mult = industry.get_multiplier(date);
557            // Industry multipliers are additive to base (they represent deviations from normal)
558            // A multiplier > 1.0 increases activity, < 1.0 decreases it
559            multiplier *= industry_mult;
560        }
561
562        multiplier
563    }
564
565    /// Get the period-end multiplier for a date.
566    ///
567    /// Returns the period-end component of the date multiplier,
568    /// using dynamics if available, otherwise legacy flat multipliers.
569    pub fn get_period_end_multiplier(&self, date: NaiveDate) -> f64 {
570        if self.use_period_end_dynamics {
571            if let Some(ref dynamics) = self.period_end_dynamics {
572                return dynamics.get_multiplier_for_date(date);
573            }
574        }
575
576        // Legacy flat multipliers
577        if self.seasonality_config.year_end_spike && self.is_year_end(date) {
578            self.seasonality_config.year_end_multiplier
579        } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
580            self.seasonality_config.quarter_end_multiplier
581        } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
582            self.seasonality_config.month_end_multiplier
583        } else {
584            1.0
585        }
586    }
587
588    /// Get the base multiplier without industry seasonality.
589    pub fn get_base_date_multiplier(&self, date: NaiveDate) -> f64 {
590        let mut multiplier = 1.0;
591
592        if self.is_weekend(date) {
593            multiplier *= self.seasonality_config.weekend_activity;
594        } else {
595            // Day-of-week patterns (only for weekdays)
596            multiplier *= self.get_day_of_week_multiplier(date);
597        }
598
599        let holiday_mult = self.get_holiday_multiplier(date);
600        if holiday_mult < 1.0 {
601            multiplier *= holiday_mult;
602        }
603
604        // Period-end spikes - use dynamics if available
605        if self.use_period_end_dynamics {
606            if let Some(ref dynamics) = self.period_end_dynamics {
607                let period_mult = dynamics.get_multiplier_for_date(date);
608                multiplier *= period_mult;
609            }
610        } else {
611            // Legacy flat multipliers
612            if self.seasonality_config.year_end_spike && self.is_year_end(date) {
613                multiplier *= self.seasonality_config.year_end_multiplier;
614            } else if self.seasonality_config.quarter_end_spike && self.is_quarter_end(date) {
615                multiplier *= self.seasonality_config.quarter_end_multiplier;
616            } else if self.seasonality_config.month_end_spike && self.is_month_end(date) {
617                multiplier *= self.seasonality_config.month_end_multiplier;
618            }
619        }
620
621        multiplier
622    }
623
624    /// Get only the industry seasonality multiplier for a date.
625    pub fn get_industry_multiplier(&self, date: NaiveDate) -> f64 {
626        self.industry_seasonality
627            .as_ref()
628            .map(|s| s.get_multiplier(date))
629            .unwrap_or(1.0)
630    }
631
632    /// Sample a posting date within a range based on seasonality.
633    pub fn sample_date(&mut self, start: NaiveDate, end: NaiveDate) -> NaiveDate {
634        let days = (end - start).num_days() as usize;
635        if days == 0 {
636            return start;
637        }
638
639        // Build weighted distribution based on activity levels
640        let mut weights: Vec<f64> = (0..=days)
641            .map(|d| {
642                let date = start + Duration::days(d as i64);
643                self.get_date_multiplier(date)
644            })
645            .collect();
646
647        // Normalize weights
648        let total: f64 = weights.iter().sum();
649        weights.iter_mut().for_each(|w| *w /= total);
650
651        // Sample using weights
652        let p: f64 = self.rng.gen();
653        let mut cumulative = 0.0;
654        for (i, weight) in weights.iter().enumerate() {
655            cumulative += weight;
656            if p < cumulative {
657                return start + Duration::days(i as i64);
658            }
659        }
660
661        end
662    }
663
664    /// Sample a posting time based on working hours.
665    pub fn sample_time(&mut self, is_human: bool) -> NaiveTime {
666        if !is_human {
667            // Automated systems can post any time, but prefer off-hours
668            let hour = if self.rng.gen::<f64>() < 0.7 {
669                // 70% off-peak hours (night batch processing)
670                self.rng.gen_range(22..=23).clamp(0, 23)
671                    + if self.rng.gen_bool(0.5) {
672                        0
673                    } else {
674                        self.rng.gen_range(0..=5)
675                    }
676            } else {
677                self.rng.gen_range(0..24)
678            };
679            let minute = self.rng.gen_range(0..60);
680            let second = self.rng.gen_range(0..60);
681            return NaiveTime::from_hms_opt(hour.clamp(0, 23) as u32, minute, second)
682                .expect("valid date/time components");
683        }
684
685        // Human users follow working hours
686        let hour = if self.rng.gen::<f64>() < self.working_hours_config.after_hours_probability {
687            // After hours
688            if self.rng.gen_bool(0.5) {
689                self.rng.gen_range(6..self.working_hours_config.day_start)
690            } else {
691                self.rng.gen_range(self.working_hours_config.day_end..22)
692            }
693        } else {
694            // Normal working hours with peak weighting
695            let is_peak = self.rng.gen::<f64>() < 0.6; // 60% during peak
696            if is_peak && !self.working_hours_config.peak_hours.is_empty() {
697                *self
698                    .working_hours_config
699                    .peak_hours
700                    .choose(&mut self.rng)
701                    .expect("valid date/time components")
702            } else {
703                self.rng.gen_range(
704                    self.working_hours_config.day_start..self.working_hours_config.day_end,
705                )
706            }
707        };
708
709        let minute = self.rng.gen_range(0..60);
710        let second = self.rng.gen_range(0..60);
711
712        NaiveTime::from_hms_opt(hour as u32, minute, second).expect("valid date/time components")
713    }
714
715    /// Calculate expected transaction count for a date given daily average.
716    pub fn expected_count_for_date(&self, date: NaiveDate, daily_average: f64) -> u64 {
717        let multiplier = self.get_date_multiplier(date);
718        (daily_average * multiplier).round() as u64
719    }
720
721    /// Reset the sampler with a new seed.
722    pub fn reset(&mut self, seed: u64) {
723        self.rng = ChaCha8Rng::seed_from_u64(seed);
724    }
725}
726
727/// Time period specification for generation.
728#[derive(Debug, Clone)]
729pub struct TimePeriod {
730    /// Start date (inclusive)
731    pub start_date: NaiveDate,
732    /// End date (inclusive)
733    pub end_date: NaiveDate,
734    /// Fiscal year
735    pub fiscal_year: u16,
736    /// Fiscal periods covered
737    pub fiscal_periods: Vec<u8>,
738}
739
740impl TimePeriod {
741    /// Create a time period for a full fiscal year.
742    pub fn fiscal_year(year: u16) -> Self {
743        Self {
744            start_date: NaiveDate::from_ymd_opt(year as i32, 1, 1)
745                .expect("valid date/time components"),
746            end_date: NaiveDate::from_ymd_opt(year as i32, 12, 31)
747                .expect("valid date/time components"),
748            fiscal_year: year,
749            fiscal_periods: (1..=12).collect(),
750        }
751    }
752
753    /// Create a time period for specific months.
754    pub fn months(year: u16, start_month: u8, num_months: u8) -> Self {
755        let start_date = NaiveDate::from_ymd_opt(year as i32, start_month as u32, 1)
756            .expect("valid date/time components");
757        let end_month = ((start_month - 1 + num_months - 1) % 12) + 1;
758        let end_year = year + (start_month as u16 - 1 + num_months as u16 - 1) / 12;
759        let end_date = TemporalSampler::last_day_of_month(
760            NaiveDate::from_ymd_opt(end_year as i32, end_month as u32, 1)
761                .expect("valid date/time components"),
762        );
763
764        Self {
765            start_date,
766            end_date,
767            fiscal_year: year,
768            fiscal_periods: (start_month..start_month + num_months).collect(),
769        }
770    }
771
772    /// Get total days in the period.
773    pub fn total_days(&self) -> i64 {
774        (self.end_date - self.start_date).num_days() + 1
775    }
776}
777
778#[cfg(test)]
779#[allow(clippy::unwrap_used)]
780mod tests {
781    use super::*;
782    use chrono::Timelike;
783
784    #[test]
785    fn test_is_weekend() {
786        let sampler = TemporalSampler::new(42);
787        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
788        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
789        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
790
791        assert!(sampler.is_weekend(saturday));
792        assert!(sampler.is_weekend(sunday));
793        assert!(!sampler.is_weekend(monday));
794    }
795
796    #[test]
797    fn test_is_month_end() {
798        let sampler = TemporalSampler::new(42);
799        let month_end = NaiveDate::from_ymd_opt(2024, 6, 28).unwrap();
800        let month_start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
801
802        assert!(sampler.is_month_end(month_end));
803        assert!(!sampler.is_month_end(month_start));
804    }
805
806    #[test]
807    fn test_date_multiplier() {
808        let sampler = TemporalSampler::new(42);
809
810        // Regular weekday (Wednesday = 1.0)
811        let regular_day = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap(); // Wednesday
812        assert!((sampler.get_date_multiplier(regular_day) - 1.0).abs() < 0.01);
813
814        // Weekend
815        let weekend = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); // Saturday
816        assert!(sampler.get_date_multiplier(weekend) < 0.2);
817
818        // Month end
819        let month_end = NaiveDate::from_ymd_opt(2024, 6, 28).unwrap();
820        assert!(sampler.get_date_multiplier(month_end) > 2.0);
821    }
822
823    #[test]
824    fn test_day_of_week_patterns() {
825        let sampler = TemporalSampler::new(42);
826
827        // June 2024: 10=Mon, 11=Tue, 12=Wed, 13=Thu, 14=Fri
828        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
829        let tuesday = NaiveDate::from_ymd_opt(2024, 6, 11).unwrap();
830        let wednesday = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
831        let thursday = NaiveDate::from_ymd_opt(2024, 6, 13).unwrap();
832        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
833
834        // Monday should have highest weekday multiplier (catch-up)
835        let mon_mult = sampler.get_day_of_week_multiplier(monday);
836        assert!((mon_mult - 1.3).abs() < 0.01);
837
838        // Tuesday slightly elevated
839        let tue_mult = sampler.get_day_of_week_multiplier(tuesday);
840        assert!((tue_mult - 1.1).abs() < 0.01);
841
842        // Wednesday/Thursday normal
843        let wed_mult = sampler.get_day_of_week_multiplier(wednesday);
844        let thu_mult = sampler.get_day_of_week_multiplier(thursday);
845        assert!((wed_mult - 1.0).abs() < 0.01);
846        assert!((thu_mult - 1.0).abs() < 0.01);
847
848        // Friday reduced (winding down)
849        let fri_mult = sampler.get_day_of_week_multiplier(friday);
850        assert!((fri_mult - 0.85).abs() < 0.01);
851
852        // Verify the pattern is applied in get_date_multiplier
853        // (excluding period-end effects)
854        assert!(sampler.get_date_multiplier(monday) > sampler.get_date_multiplier(friday));
855    }
856
857    #[test]
858    fn test_sample_time_human() {
859        let mut sampler = TemporalSampler::new(42);
860
861        for _ in 0..100 {
862            let time = sampler.sample_time(true);
863            // Most times should be during working hours
864            let hour = time.hour();
865            // Just verify it's a valid time
866            assert!(hour < 24);
867        }
868    }
869
870    #[test]
871    fn test_time_period() {
872        let period = TimePeriod::fiscal_year(2024);
873        assert_eq!(period.total_days(), 366); // 2024 is leap year
874
875        let partial = TimePeriod::months(2024, 1, 6);
876        assert!(partial.total_days() > 180);
877        assert!(partial.total_days() < 185);
878    }
879}