Coverage Report

Created: 2025-08-13 21:01

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/a220/proj/radnelac/src/calendar/french_rev_arith.rs
Line
Count
Source
1
// This Source Code Form is subject to the terms of the Mozilla Public
2
// License, v. 2.0. If a copy of the MPL was not distributed with this
3
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5
use crate::calendar::gregorian::Gregorian;
6
use crate::calendar::prelude::CommonDate;
7
use crate::calendar::prelude::HasLeapYears;
8
use crate::calendar::prelude::Perennial;
9
use crate::calendar::prelude::Quarter;
10
use crate::calendar::prelude::ToFromCommonDate;
11
use crate::calendar::AllowYearZero;
12
use crate::calendar::CalendarMoment;
13
use crate::calendar::HasIntercalaryDays;
14
use crate::calendar::OrdinalDate;
15
use crate::calendar::ToFromOrdinalDate;
16
use crate::common::error::CalendarError;
17
use crate::common::math::TermNum;
18
use crate::day_count::BoundedDayCount;
19
use crate::day_count::CalculatedBounds;
20
use crate::day_count::Epoch;
21
use crate::day_count::Fixed;
22
use crate::day_count::FromFixed;
23
use crate::day_count::ToFixed;
24
#[allow(unused_imports)] //FromPrimitive is needed for derive
25
use num_traits::FromPrimitive;
26
use std::num::NonZero;
27
28
const FRENCH_EPOCH_GREGORIAN: CommonDate = CommonDate {
29
    year: 1792,
30
    month: 9,
31
    day: 22,
32
};
33
const NON_MONTH: u8 = 13;
34
35
/// Represents a month in the French Revolutionary Calendar
36
///
37
/// Note that the Sansculottides at the end of the French Revolutionary calendar
38
/// year have no month and thus are not represented by FrenchRevMonth. When representing
39
/// an arbitrary day in the French Revolutionary calendar, use an `Option<FrenchRevMonth>`
40
/// for the the month field.
41
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, FromPrimitive, ToPrimitive)]
42
pub enum FrenchRevMonth {
43
    Vendemiaire = 1,
44
    Brumaire,
45
    Frimaire,
46
    Nivose,
47
    Pluviose,
48
    Ventose,
49
    Germinal,
50
    Floreal,
51
    Prairial,
52
    Messidor,
53
    Thermidor,
54
    Fructidor,
55
}
56
57
/// Represents a weekday in the French Revolutionary Calendar
58
///
59
/// The calendar reforms during the French Revolution included the creation of
60
/// a ten-day week. The name of each day is based on the numeric position in the week.
61
///
62
/// Note that the Sansculottides at the end of the French Revolutionary calendar
63
/// year do not have a weekday.
64
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, FromPrimitive, ToPrimitive)]
65
pub enum FrenchRevWeekday {
66
    Primidi = 1,
67
    Duodi,
68
    Tridi,
69
    Quartidi,
70
    Quintidi,
71
    Sextidi,
72
    Septidi,
73
    Octidi,
74
    Nonidi,
75
    Decadi,
76
}
77
78
/// Represents an epagomenal day at the end of the French Revolutionary calendar year
79
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, FromPrimitive, ToPrimitive)]
80
pub enum Sansculottide {
81
    Vertu = 1,
82
    Genie,
83
    Travail,
84
    Opinion,
85
    Recompense,
86
    Revolution,
87
}
88
89
/// Represents a date in the algorithmic approximation of the French Revolutionary calendar
90
/// (ie. French Republican calendar)
91
///
92
/// The calendar actually implemented during the French First Republic relied on astronomical
93
/// observations to determine whether a given year was a leap year. **FrenchRevArith does not
94
/// read astronomical data nor approximate such data - instead it relies on algorithmic
95
/// rules to determine the start of new years, similar to those used by the Gregorian calendar**.
96
///
97
/// The leap year rule is determined by the parameter L.
98
/// * L = false: According to this rule, any year which is a multiple of 4 is a leap year
99
///    unless it is a multiple of 100. Any year which is a multiple of 100 is not a leap year
100
///    unless it is a multiple of 400. Any year which is a multiple of 400 is a leap year
101
///    unless it is a multiple of 4000. For example, years 4, 8, and 12 are leap years.
102
/// * L = true: This approximation is exactly the same as the one used where L = false, except
103
///    that an offset of 1 is added to the year before starting the calculation. For example,
104
///    years 3, 7 and 11 are leap years.
105
///
106
/// The approximation where L = false was proposed by Gilbert Romme, who directed the creation
107
/// of the calendar. It is commonly used by other software approximating the French Revolutionary
108
/// calendar. However, it was never used by any French government -
109
/// the calendar actually used during the French First Republic used astronomical observations
110
/// to determine leap years, and contradicted Romme's approximations. The official leap years
111
/// during the Revolution were years 3, 7, and 11 whereas the leap years produced by Romme's
112
/// approximation are years 4, 8, and 12.
113
///
114
/// The approximation where L = true ensures that leap years are consistent with the French
115
/// government for the years where the Revolutionary calendar was officially used. This is a
116
/// rather crude approximation which is not astronomically accurate outside those particular
117
/// years.
118
///
119
/// The value of L should be determined by the caller's use case:
120
/// * for consistency with other software using Romme's approximation: L = false
121
/// * for consistency with Romme's wishes: L = false
122
/// * for consistency with historical dates during the French First Republic: L = true
123
/// * for consistency with historical dates during the Paris Commune: L = true
124
/// * for consistency with how the calendar was "originally intended" to work for
125
///   time periods not mentioned above: **not supported**
126
///
127
/// The final use case in the list above is not currently supported by this library.
128
/// Implementing that feature requires calculating the date of the autumnal equinox
129
/// at the Paris Observatory. If a future version of this library implements such
130
/// astronomical calculations, those calculations will not be provided by FrenchRevArith.
131
/// Instead, such calculations shall be provided by a new struct with a new name.
132
///
133
/// ## Further reading
134
/// + [Wikipedia](https://en.wikipedia.org/wiki/French_Republican_calendar)
135
/// + [Guanzhong "quantum" Chen](https://quantum5.ca/2022/03/09/art-of-time-keeping-part-4-french-republican-calendar/)
136
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
137
pub struct FrenchRevArith<const L: bool>(CommonDate);
138
139
impl<const L: bool> AllowYearZero for FrenchRevArith<L> {}
140
141
impl<const L: bool> ToFromOrdinalDate for FrenchRevArith<L> {
142
2.56k
    fn valid_ordinal(ord: OrdinalDate) -> Result<(), CalendarError> {
143
2.56k
        let correction = if Self::is_leap(ord.year) { 
1620
} else {
01.94k
};
144
2.56k
        if ord.day_of_year > 0 && 
ord.day_of_year <= (365 + correction)2.04k
{
145
1.14k
            Ok(())
146
        } else {
147
1.42k
            Err(CalendarError::InvalidDayOfYear)
148
        }
149
2.56k
    }
150
151
31.7k
    fn ordinal_from_fixed(fixed_date: Fixed) -> OrdinalDate {
152
        //LISTING 17.10 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
153
        //This does not include the month and day terms
154
31.7k
        let date = fixed_date.get_day_i();
155
31.7k
        let epoch = Self::epoch().get_day_i();
156
31.7k
        let approx = ((4000 * (date - epoch + 2)).div_euclid(1460969) + 1) as i32;
157
31.7k
        let approx_start = Self(CommonDate::new(approx, 1, 1)).to_fixed().get_day_i();
158
31.7k
        let year = if date < approx_start {
159
167
            approx - 1
160
        } else {
161
31.5k
            approx
162
        };
163
31.7k
        let year_start = Self(CommonDate::new(year, 1, 1)).to_fixed().get_day_i();
164
31.7k
        let doy = (date - year_start + 1) as u16;
165
31.7k
        OrdinalDate {
166
31.7k
            year: year,
167
31.7k
            day_of_year: doy,
168
31.7k
        }
169
31.7k
    }
170
171
109k
    fn to_ordinal(self) -> OrdinalDate {
172
        //LISTING 17.9 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
173
        //This is only the terms relying on month and day.
174
109k
        let offset_m = 30 * ((self.0.month as u16) - 1);
175
109k
        let offset_d = self.0.day as u16;
176
109k
        OrdinalDate {
177
109k
            year: self.0.year,
178
109k
            day_of_year: offset_m + offset_d,
179
109k
        }
180
109k
    }
181
182
31.2k
    fn from_ordinal_unchecked(ord: OrdinalDate) -> Self {
183
        //LISTING 17.10 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
184
        //This is only the terms relying on month and day.
185
        //This is modified to use ordinal days instead of days from epoch.
186
31.2k
        let month = (1 + (ord.day_of_year - 1).div_euclid(30)) as u8;
187
31.2k
        let month_start = Self(CommonDate::new(ord.year, month, 1)).to_ordinal();
188
31.2k
        let day = (1 + ord.day_of_year - month_start.day_of_year) as u8;
189
31.2k
        FrenchRevArith(CommonDate::new(ord.year, month, day))
190
31.2k
    }
191
}
192
193
impl<const L: bool> FrenchRevArith<L> {
194
    /// Returns L
195
0
    pub fn is_adjusted(self) -> bool {
196
0
        L
197
0
    }
198
}
199
200
impl<const L: bool> HasIntercalaryDays<Sansculottide> for FrenchRevArith<L> {
201
12.3k
    fn complementary(self) -> Option<Sansculottide> {
202
12.3k
        if self.0.month == NON_MONTH {
203
5.78k
            Sansculottide::from_u8(self.0.day)
204
        } else {
205
6.57k
            None
206
        }
207
12.3k
    }
208
209
16.3k
    fn complementary_count(f_year: i32) -> u8 {
210
16.3k
        if FrenchRevArith::<L>::is_leap(f_year) {
211
5.18k
            6
212
        } else {
213
11.1k
            5
214
        }
215
16.3k
    }
216
}
217
218
impl<const L: bool> Perennial<FrenchRevMonth, FrenchRevWeekday> for FrenchRevArith<L> {
219
18.8k
    fn weekday(self) -> Option<FrenchRevWeekday> {
220
18.8k
        if self.0.month == NON_MONTH {
221
129
            None
222
        } else {
223
18.6k
            FrenchRevWeekday::from_i64((self.0.day as i64).adjusted_remainder(10))
224
        }
225
18.8k
    }
226
227
5.07k
    fn days_per_week() -> u8 {
228
5.07k
        10
229
5.07k
    }
230
231
5.04k
    fn weeks_per_month() -> u8 {
232
5.04k
        3
233
5.04k
    }
234
}
235
236
impl<const L: bool> HasLeapYears for FrenchRevArith<L> {
237
20.7k
    fn is_leap(year: i32) -> bool {
238
        //LISTING 17.8 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
239
        //Modified to use L
240
20.7k
        let f_year = if L { 
year + 19.69k
} else {
year11.0k
};
241
20.7k
        let m4 = f_year.modulus(4);
242
20.7k
        let m400 = f_year.modulus(400);
243
20.7k
        let m4000 = f_year.modulus(4000);
244
20.7k
        m4 == 0 && (
m400 != 1006.67k
&&
m400 != 2006.57k
&&
m400 != 3006.49k
) &&
m4000 != 06.44k
245
20.7k
    }
246
}
247
248
impl<const L: bool> CalculatedBounds for FrenchRevArith<L> {}
249
250
impl<const L: bool> Epoch for FrenchRevArith<L> {
251
108k
    fn epoch() -> Fixed {
252
108k
        Gregorian::try_from_common_date(FRENCH_EPOCH_GREGORIAN)
253
108k
            .expect("Epoch known to be valid")
254
108k
            .to_fixed()
255
108k
    }
256
}
257
258
impl<const L: bool> FromFixed for FrenchRevArith<L> {
259
30.7k
    fn from_fixed(fixed_date: Fixed) -> FrenchRevArith<L> {
260
        //LISTING 17.10 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
261
        //Split compared to original
262
30.7k
        let ord = Self::ordinal_from_fixed(fixed_date);
263
30.7k
        Self::from_ordinal_unchecked(ord)
264
30.7k
    }
265
}
266
267
impl<const L: bool> ToFixed for FrenchRevArith<L> {
268
73.7k
    fn to_fixed(self) -> Fixed {
269
        //LISTING 17.9 (*Calendrical Calculations: The Ultimate Edition* by Reingold & Dershowitz.)
270
        //Split compared to original: terms relying on month and day are processed in to_ordinal
271
73.7k
        let year = self.0.year as i64;
272
73.7k
        let y_adj = if L { 
136.8k
} else {
036.8k
};
273
274
73.7k
        let offset_e = Self::epoch().get_day_i() - 1;
275
73.7k
        let offset_y = 365 * (year - 1);
276
73.7k
        let offset_leap = (year + y_adj - 1).div_euclid(4) - (year + y_adj - 1).div_euclid(100)
277
73.7k
            + (year + y_adj - 1).div_euclid(400)
278
73.7k
            - (year + y_adj - 1).div_euclid(4000);
279
73.7k
        let ord = self.to_ordinal().day_of_year as i64;
280
73.7k
        Fixed::cast_new(offset_e + offset_y + offset_leap + ord)
281
73.7k
    }
282
}
283
284
impl<const L: bool> ToFromCommonDate<FrenchRevMonth> for FrenchRevArith<L> {
285
74.9k
    fn to_common_date(self) -> CommonDate {
286
74.9k
        self.0
287
74.9k
    }
288
289
21.7k
    fn from_common_date_unchecked(date: CommonDate) -> Self {
290
21.7k
        debug_assert!(Self::valid_ymd(date).is_ok());
291
21.7k
        Self(date)
292
21.7k
    }
293
294
46.5k
    fn valid_ymd(date: CommonDate) -> Result<(), CalendarError> {
295
46.5k
        if date.month < 1 || 
date.month > NON_MONTH46.0k
{
296
1.53k
            Err(CalendarError::InvalidMonth)
297
45.0k
        } else if date.day < 1 {
298
512
            Err(CalendarError::InvalidDay)
299
44.5k
        } else if date.month < NON_MONTH && 
date.day > 3028.6k
{
300
512
            Err(CalendarError::InvalidDay)
301
44.0k
        } else if date.month == NON_MONTH
302
15.8k
            && date.day > FrenchRevArith::<L>::complementary_count(date.year)
303
        {
304
0
            Err(CalendarError::InvalidDay)
305
        } else {
306
44.0k
            Ok(())
307
        }
308
46.5k
    }
309
310
514
    fn year_end_date(year: i32) -> CommonDate {
311
514
        CommonDate::new(
312
514
            year,
313
            NON_MONTH,
314
514
            FrenchRevArith::<L>::complementary_count(year),
315
        )
316
514
    }
317
}
318
319
impl<const L: bool> Quarter for FrenchRevArith<L> {
320
5.63k
    fn quarter(self) -> NonZero<u8> {
321
5.63k
        let m = self.to_common_date().month;
322
5.63k
        if m == NON_MONTH {
323
523
            NonZero::new(4 as u8).expect("4 != 0")
324
        } else {
325
5.10k
            NonZero::new(((m - 1) / 3) + 1).expect("(m-1)/3 > -1")
326
        }
327
5.63k
    }
328
}
329
330
/// Represents a date *and time* in the algorithmic approximation of the French Revolutionary Calendar
331
pub type FrenchRevArithMoment<const L: bool> = CalendarMoment<FrenchRevArith<L>>;
332
333
#[cfg(test)]
334
mod tests {
335
    use super::*;
336
    use proptest::proptest;
337
338
    #[test]
339
1
    fn leaps() {
340
1
        assert!(FrenchRevArith::<true>::is_leap(3));
341
1
        assert!(FrenchRevArith::<true>::is_leap(7));
342
1
        assert!(FrenchRevArith::<true>::is_leap(11));
343
1
        assert!(FrenchRevArith::<false>::is_leap(4));
344
1
        assert!(FrenchRevArith::<false>::is_leap(8));
345
1
        assert!(FrenchRevArith::<false>::is_leap(12));
346
1
    }
347
348
    #[test]
349
1
    fn revolutionary_events() {
350
        // https://en.wikipedia.org/wiki/Glossary_of_the_French_Revolution#Events_commonly_known_by_their_Revolutionary_dates
351
        // 13 Vendémiaire and 18 Brumaire can be mangled when L = false
352
1
        let event_list = [
353
1
            (
354
1
                CommonDate::new(2, FrenchRevMonth::Prairial as u8, 22),
355
1
                CommonDate::new(2, FrenchRevMonth::Prairial as u8, 22),
356
1
                CommonDate::new(1794, 6, 10),
357
1
            ),
358
1
            (
359
1
                CommonDate::new(2, FrenchRevMonth::Thermidor as u8, 9),
360
1
                CommonDate::new(2, FrenchRevMonth::Thermidor as u8, 9),
361
1
                CommonDate::new(1794, 7, 27),
362
1
            ),
363
1
            (
364
1
                CommonDate::new(4, FrenchRevMonth::Vendemiaire as u8, 13),
365
1
                CommonDate::new(4, FrenchRevMonth::Vendemiaire as u8, 13 + 1), //Supposed to be 13
366
1
                CommonDate::new(1795, 10, 5),
367
1
            ),
368
1
            (
369
1
                CommonDate::new(5, FrenchRevMonth::Fructidor as u8, 18),
370
1
                CommonDate::new(5, FrenchRevMonth::Fructidor as u8, 18),
371
1
                CommonDate::new(1797, 9, 4),
372
1
            ),
373
1
            (
374
1
                CommonDate::new(6, FrenchRevMonth::Floreal as u8, 22),
375
1
                CommonDate::new(6, FrenchRevMonth::Floreal as u8, 22),
376
1
                CommonDate::new(1798, 5, 11),
377
1
            ),
378
1
            (
379
1
                CommonDate::new(7, FrenchRevMonth::Prairial as u8, 30),
380
1
                CommonDate::new(7, FrenchRevMonth::Prairial as u8, 30),
381
1
                CommonDate::new(1799, 6, 18),
382
1
            ),
383
1
            (
384
1
                CommonDate::new(8, FrenchRevMonth::Brumaire as u8, 18),
385
1
                CommonDate::new(8, FrenchRevMonth::Brumaire as u8, 18 + 1), //Supposed to be 18
386
1
                CommonDate::new(1799, 11, 9),
387
1
            ),
388
1
            // Paris Commune
389
1
            (
390
1
                CommonDate::new(79, FrenchRevMonth::Floreal as u8, 16),
391
1
                CommonDate::new(79, FrenchRevMonth::Floreal as u8, 16),
392
1
                CommonDate::new(1871, 5, 6),
393
1
            ),
394
1
        ];
395
9
        for 
pair8
in event_list {
396
8
            let df0 = FrenchRevArith::<true>::try_from_common_date(pair.0)
397
8
                .unwrap()
398
8
                .to_fixed();
399
8
            let df1 = FrenchRevArith::<false>::try_from_common_date(pair.1)
400
8
                .unwrap()
401
8
                .to_fixed();
402
8
            let dg = Gregorian::try_from_common_date(pair.2).unwrap().to_fixed();
403
8
            assert_eq!(df0, dg);
404
8
            assert_eq!(df1, dg);
405
        }
406
1
    }
407
408
    proptest! {
409
        #[test]
410
        fn align_to_gregorian(year in 0..100) {
411
            // https://en.wikipedia.org/wiki/French_Republican_calendar
412
            // > Autumn:
413
            // >     Vendémiaire (...), starting 22, 23, or 24 September
414
            // >     Brumaire (...), starting 22, 23, or 24 October
415
            // >     Frimaire (...), starting 21, 22, or 23 November
416
            // > Winter:
417
            // >     Nivôse (...), starting 21, 22, or 23 December
418
            // >     Pluviôse (...), starting 20, 21, or 22 January
419
            // >     Ventôse (...), starting 19, 20, or 21 February
420
            // > Spring:
421
            // >     Germinal (...), starting 21 or 22 March
422
            // >     Floréal (...), starting 20 or 21 April
423
            // >     Prairial (...), starting 20 or 21 May
424
            // > Summer:
425
            // >     Messidor (...), starting 19 or 20 June
426
            // >     Thermidor (...), starting 19 or 20 July; ...
427
            // >     Fructidor (...), starting 18 or 19 August
428
            // Not clear how long this property is supposed to hold, given
429
            // the differing leap year rule. There can be off by one errors
430
            // if L is false.
431
            let d_list = [
432
                ( CommonDate{ year, month: 1, day: 1 }, 9, 22, 24),
433
                ( CommonDate{ year, month: 2, day: 1 }, 10, 22, 24),
434
                ( CommonDate{ year, month: 3, day: 1 }, 11, 21, 23),
435
                ( CommonDate{ year, month: 4, day: 1 }, 12, 21, 23),
436
                ( CommonDate{ year, month: 5, day: 1 }, 1, 20, 22),
437
                ( CommonDate{ year, month: 6, day: 1 }, 2, 19, 21),
438
                ( CommonDate{ year, month: 7, day: 1 }, 3, 21, 22),
439
                ( CommonDate{ year, month: 8, day: 1 }, 4, 20, 21),
440
                ( CommonDate{ year, month: 9, day: 1 }, 5, 20, 21),
441
                ( CommonDate{ year, month: 10, day: 1 }, 6, 19, 20),
442
                ( CommonDate{ year, month: 11, day: 1 }, 7, 19, 20),
443
                ( CommonDate{ year, month: 12, day: 1 }, 8, 18, 19),
444
            ];
445
            for item in d_list {
446
                let r0 = FrenchRevArith::<true>::try_from_common_date(item.0).unwrap();
447
                let f0 = r0.to_fixed();
448
                let r1 = FrenchRevArith::<false>::try_from_common_date(item.0).unwrap();
449
                let f1 = r1.to_fixed();
450
                let g = Gregorian::from_fixed(f0);
451
                let gc = g.to_common_date();
452
                assert_eq!(gc.month, item.1);
453
                assert!(item.2 <= gc.day && item.3 >= gc.day);
454
                assert!((f1.get_day_i() - f0.get_day_i()).abs() < 2);
455
            }
456
        }
457
    }
458
}