rslife 0.2.13

A comprehensive Rust library for actuarial mortality table calculations and life insurance mathematics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
use super::aga_xls::AusGovActMortXLS;
use super::ifoa_xls::IFOAMortXLS;
use super::soa_xml::SOAMortXML;
use crate::RSLifeResult;
use crate::mt_config::spreadsheet_helpers::*;
use bon::bon;
use calamine::{Reader, open_workbook_auto};
use polars::prelude::*;
use spreadsheet_ods::read_ods;
use std::fs;

#[derive(Debug, Clone)]
pub struct MortData {
    pub category: String,
    pub description: String,
    pub dataframe: DataFrame,
}

#[bon]
impl MortData {
    /// Create a new MortData instance with custom category and DataFrame.
    ///
    /// This is the primary constructor that validates the DataFrame schema before creating
    /// the MortData instance. The DataFrame must conform to mortality table requirements.
    ///
    /// # Schema Requirements
    /// - Must have 2 or 3 columns
    /// - First column: "age" (f64, but must contain whole numbers)
    /// - Second column: "qx" or "lx" (f64)
    /// - Optional third column: "duration" (f64, but must contain whole numbers)
    /// - All values must be non-negative
    /// - qx values must be ≤ 1.0
    ///
    /// # Errors
    /// - Invalid DataFrame schema
    /// - Incorrect column names or types
    /// - Invalid data values (negative, qx > 1.0)
    /// - Empty DataFrame
    ///
    /// # Examples
    /// ```rust, ignore
    /// # use rslife::prelude::*;
    /// use polars::prelude::*;
    ///
    /// let df = df! {
    ///     "age" => [20_u32, 21, 22],
    ///     "qx" => [0.001_f64, 0.002, 0.003]
    /// }?;
    ///
    /// let mort_data = MortData::new("Custom Table".to_string(), "Test", df)?;
    /// assert_eq!(mort_data.category, "Custom Table");
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn new(category: String, description: String, dataframe: DataFrame) -> RSLifeResult<Self> {
        // Validate DataFrame schema first
        validate_df_schema(&dataframe)?;

        // Validate and transform DataFrame to ensure it has the correct schema
        let dataframe = setup_dataframe_to_correct_schema(dataframe)
            .map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?;

        // Return result
        let result = Self {
            category,
            description,
            dataframe,
        };

        Ok(result)
    }

    // ========================================================
    // Parametric Mortality Laws
    // ========================================================

    /// Create a parametric mortality table using the Constant Force Law.
    ///
    /// The force of mortality is constant:
    /// ```text
    /// μₓ = λ
    /// ```
    /// Survival functions:
    /// ```text
    /// S₀(x) = exp(-λx)
    /// ₜpₓ = S₀(x + t) / S₀(x) = exp(-λt)
    /// ```
    /// Mortality rate:
    /// ```text
    /// qₓ = 1 - pₓ = 1 - exp(-λ)
    /// ```
    ///
    /// # Parameters
    /// - `lambda`: Force of mortality (λ), must be positive.
    /// - `start_age`: Starting age for the table (default: 0).
    /// - `omega`: Limiting age for the table (default: 150).
    ///
    /// # Errors
    /// - Lambda must be positive.
    ///
    /// # Example
    /// ```rust
    /// # use rslife::prelude::*;
    /// let data = MortData::from_Constant_Force_law()
    ///     .lambda(0.08)
    ///     .start_age(30)
    ///     .call()?;
    ///# RSLifeResult::Ok(())
    /// ```
    #[builder]
    pub fn from_Constant_Force_law(
        lambda: f64,
        #[builder(default = 0)] start_age: u32,
        #[builder(default = 150)] omega: u32, // end_age
    ) -> RSLifeResult<Self> {
        // Constant force of mortality law:
        // μₓ = λ
        // S₀(x) = exp(-λx)
        // ₜpₓ = exp(-λt)
        // qₓ = 1 - exp(-λ)

        if lambda <= 0.0 {
            return Err("Lambda must be positive".into());
        }

        let ages: Vec<u32> = (start_age..=omega).collect();
        let qx: Vec<f64> = ages
            .iter()
            .map(|&x| 1.0 - (-lambda * x as f64).exp())
            .collect();

        let data = df! {
            "age" => ages,
            "qx" => qx,
        }?;

        // Create MortData from the first table in the XML
        let category = "Parametric Mortality Data".to_string();
        let description = "Constant Force Law".to_string();
        Self::new(category, description, data)
    }

    /// Create a parametric mortality table using the De Moirve Law.
    ///
    /// The force of mortality is inversely related to limiting age:
    /// ```text
    /// μₓ = 1/(ω - x)
    /// ```
    /// Survival functions:
    /// ```text
    /// S₀(x) = 1 - x/ω
    /// ₜpₓ = S₀(x + t) / S₀(x) = (1 - (x + t)/ω) / (1 - x/ω)
    /// ```
    /// Mortality rate:
    /// ```text
    /// qₓ = 1/(ω - x)
    /// ```
    ///
    /// # Parameters
    /// - `start_age`: Starting age for the table (default: 0).
    /// - `omega`: Limiting age for the table (default: 150).
    ///
    /// # Example
    /// ```rust
    /// # use rslife::prelude::*;
    /// let data = MortData::from_DeMoirve_law()
    ///     .start_age(30)
    ///     .omega(120)
    ///     .call()?;
    /// # RSLifeResult::Ok(())
    /// ```
    #[builder]
    pub fn from_DeMoirve_law(
        #[builder(default = 0)] start_age: u32,
        #[builder(default = 150)] omega: u32, // end_age
    ) -> RSLifeResult<Self> {
        // De Moirve law:
        // μₓ = 1/(ω - x) for 0 ≤ x < ω
        // S₀(x) = 1-(x/ω)
        // ₜpₓ = S₀(x + t) / S₀(x) = 1 - t/(ω-x)
        // qₓ = 1 - (1 - 1/(ω-x)) = 1/(ω-x)
        let ages: Vec<u32> = (start_age..omega).collect(); // This excludes omega
        let qx: Vec<f64> = ages
            .iter()
            .map(|&x| 1.0 - (x as f64 / omega as f64))
            .collect();

        let data = df! {
            "age" => ages,
            "qx" => qx,
        }?;

        // Create MortData from the first table in the XML
        let category = "Parametric Mortality Data".to_string();
        let description = "De Moirve Law".to_string();
        Self::new(category, description, data)
    }

    /// Create a parametric mortality table using the Gompertz Law.
    ///
    /// The force of mortality increases exponentially with age:
    /// ```text
    /// μₓ = B·Cˣ
    /// ```
    /// Survival functions:
    /// ```text
    /// S₀(x) = exp(-B·(Cˣ - 1)/ln(C))
    /// ₜpₓ = S₀(x + t) / S₀(x) = exp(-B·Cˣ·(Cᵗ - 1)/ln(C))
    /// ```
    /// Mortality rate:
    /// ```text
    /// qₓ = 1 - exp(-B·Cˣ·(C - 1)/ln(C))
    /// ```
    ///
    /// # Parameters
    /// - `B`: Gompertz parameter, must be > 0.
    /// - `C`: Gompertz parameter, must be > 1.
    /// - `start_age`: Starting age for the table (default: 0).
    /// - `omega`: Limiting age for the table (default: 150).
    ///
    /// # Errors
    /// - B must be > 0, C must be > 1.
    ///
    /// # Example
    /// ```rust
    /// # use rslife::prelude::*;
    /// let data = MortData::from_Gompertz_law()
    ///     .B(0.0005)
    ///     .C(1.08)
    ///     .start_age(40)
    ///     .omega(110)
    ///     .call()?;
    /// # RSLifeResult::Ok(())
    /// ```
    #[builder]
    pub fn from_Gompertz_law(
        B: f64,
        C: f64,
        #[builder(default = 0)] start_age: u32,
        #[builder(default = 150)] omega: u32, // end_age,
    ) -> RSLifeResult<Self> {
        // Gompertz law:
        // μₓ = B.Cˣ
        // S₀(x) = exp[-B/log(C) * (Cˣ - 1)] x ≥ 0, B > 0, c > 1
        // ₜpₓ = S₀(x + t) / S₀(x) = exp[-B/log(C).Cˣ.(Cᵗ - 1)]
        // qₓ = 1 - exp[-B/log(C).Cˣ.(C - 1)]

        // Validate
        if B <= 0.0 || C <= 1.0 {
            return Err("Gompertz parameters must be B > 0 and C > 1".into());
        }

        let ages: Vec<u32> = (start_age..=omega).collect();
        let qx: Vec<f64> = ages
            .iter()
            .map(|&x| {
                let x = f64::from(x);
                let exponent = -B / C.ln() * (C.powf(x) * (C - 1.0));
                1.0 - exponent.exp()
            })
            .collect();

        // Keep 1 qx value equals to 1.0
        let data = keep_first_qx_1_remove_the_rest(ages, qx)?;

        // Create MortData from the first table in the XML
        let category = "Parametric Mortality Data".to_string();
        let description = "Gompertz Law".to_string();
        Self::new(category, description, data)
    }

    /// Create a parametric mortality table using the Makeham Law.
    ///
    /// Adds a constant to Gompertz:
    /// ```text
    /// μₓ = A + B·Cˣ
    /// ```
    /// Survival functions:
    /// ```text
    /// S₀(x) = exp(-A·x - B·(Cˣ - 1)/ln(C))
    /// ₜpₓ = S₀(x + t) / S₀(x) = exp(-A·t - B·Cˣ·(Cᵗ - 1)/ln(C))
    /// ```
    /// Mortality rate:
    /// ```text
    /// qₓ = 1 - exp(-A - B·Cˣ·(C - 1)/ln(C))
    /// ```
    ///
    /// # Parameters
    /// - `A`: Makeham parameter, must be >= -B.
    /// - `B`: Makeham parameter, must be > 0.
    /// - `C`: Makeham parameter, must be > 1.
    /// - `start_age`: Starting age for the table (default: 0).
    /// - `omega`: Limiting age for the table (default: 150).
    ///
    /// # Errors
    /// - B must be > 0, C must be > 1, A must be >= -B.
    ///
    /// # Example
    /// ```rust
    /// # use rslife::prelude::*;
    /// let data = MortData::from_Makeham_law()
    ///     .A(0.00022)
    ///     .B(2.7e-6)
    ///     .C(1.124)
    ///     .start_age(20)
    ///     .omega(120)
    ///     .call()?;
    /// # RSLifeResult::Ok(())
    /// ```
    #[builder]
    pub fn from_Makeham_law(
        A: f64,
        B: f64,
        C: f64,
        #[builder(default = 0)] start_age: u32,
        #[builder(default = 150)] omega: u32, // end_age
    ) -> RSLifeResult<Self> {
        // Makeham law:
        // μₓ = A + B.Cˣ  x ≥ 0, B > 0, c > 1, A >= -B
        // S₀(x) = exp(-Ax - B  / ln(C) * (Cˣ - 1))
        // ₜpₓ = S₀(x + t) / S₀(x) = exp[-At - B / ln(C). Cˣ(Cᵗ - 1)]
        // qₓ = 1 - exp[-A - B / ln(C).Cˣ.(C - 1)]

        // Validate parameters
        if B <= 0.0 || C <= 1.0 || A < -B {
            return Err("Makeham parameters must be B > 0, C > 1, and A >= -B".into());
        }

        let ages: Vec<u32> = (start_age..=omega).collect();
        let qx: Vec<f64> = ages
            .iter()
            .map(|&x| {
                let x = f64::from(x);
                let exponent = -A - B / C.ln() * C.powf(x) * (C - 1.0);
                1.0 - exponent.exp()
            })
            .collect();

        // Keep 1 qx value equals to 1.0
        let data = keep_first_qx_1_remove_the_rest(ages, qx)?;

        // Create MortData from the first table in the XML
        let category = "Parametric Mortality Data".to_string();
        let description = "MakeHam Law".to_string();
        Self::new(category, description, data)
    }

    /// Create a parametric mortality table using the Weibull Law.
    ///
    /// The force of mortality follows a Weibull distribution:
    /// ```text
    /// μₓ = k·xⁿ
    /// ```
    /// Survival functions:
    /// ```text
    /// S₀(x) = exp(-k/(n+1)xⁿ⁺¹)
    /// ₜpₓ = S₀(x + t) / S₀(x) = exp{-k/(n+1).[(x + t)ⁿ⁺¹ - xⁿ⁺¹)]}
    /// ```
    /// Mortality rate:
    /// ```text
    /// qₓ = 1 - exp{-k/(n+1).[(x + 1)ⁿ⁺¹ - xⁿ⁺¹)]}
    /// ```
    ///
    /// # Parameters
    /// - `k`: Weibull parameter, must be > 0.
    /// - `n`: Weibull parameter, must be > 1.
    /// - `start_age`: Starting age for the table (default: 0).
    /// - `omega`: Limiting age for the table (default: 150).
    ///
    /// # Errors
    /// - k must be > 0, n must be > 1.
    ///
    /// # Example
    /// ```rust
    /// # use rslife::prelude::*;
    /// let data = MortData::from_Weibull_law()
    ///     .k(0.0001)
    ///     .n(2.5)
    ///     .start_age(30)
    ///     .omega(120)
    ///     .call()?;
    /// # RSLifeResult::Ok(())
    /// ```
    #[builder]
    pub fn from_Weibull_law(
        k: f64,
        n: f64,
        #[builder(default = 0)] start_age: u32,
        #[builder(default = 150)] omega: u32, // end_age
    ) -> RSLifeResult<Self> {
        // Weibull law:
        // μₓ = kxⁿ  k > 0, n > 1
        // S₀(x) = exp(-k/(n+1)xⁿ⁺¹)
        // ₜpₓ = S₀(x + t) / S₀(x) = exp{-k/(n+1).[(x + t)ⁿ⁺¹ - xⁿ⁺¹)]}
        // qₓ = 1 - exp{-k/(n+1).[(x + 1)ⁿ⁺¹ - xⁿ⁺¹)]}

        // Validate parameters
        if k <= 0.0 || n <= 1.0 {
            return Err("Weibull parameters must be k > 0, n > 1".into());
        }

        let ages: Vec<u32> = (start_age..=omega).collect();
        let qx: Vec<f64> = ages
            .iter()
            .map(|&x| {
                let x = f64::from(x);
                let exponent = -k / (n + 1.0) * ((x + 1.0).powf(n + 1.0) - x.powf(n + 1.0));
                1.0 - exponent.exp()
            })
            .collect();

        // Keep 1 qx value equals to 1.0
        let data = keep_first_qx_1_remove_the_rest(ages, qx)?;

        // Create MortData from the first table in the XML
        let category = "Parametric Mortality Data".to_string();
        let description = "MakeHam Law".to_string();
        Self::new(category, description, data)
    }

    // ========================================================
    // SOA XML PARSING
    // ========================================================

    /// Parse mortality table from SOA XML string format.
    ///
    /// Parses XML data directly from a string containing SOA (Society of Actuaries)
    /// mortality table data in XML format. The XML must conform to SOA standards
    /// and contain approved mortality table types.
    ///
    /// Only approved table layouts and content types are accepted:
    /// - Table layouts: "Aggregate", "Ultimate", "Select", "Select & Ultimate"
    /// - Content types: Various mortality and disability tables (see source for full list)
    ///
    /// Schema validation is performed by `new()` after parsing.
    ///
    /// # Errors
    /// - Invalid XML format
    /// - XML parsing errors
    /// - Unsupported or unapproved table types
    /// - Schema validation errors (via `new()`)
    /// - Missing required XML elements
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// let xml_content = std::fs::read_to_string("data/elt15_f.xml")?;
    /// let mort_data = MortData::from_soa_xml_string(&xml_content)?;
    /// assert_eq!(mort_data.category, "SOA Mortality Data");
    /// assert!(mort_data.dataframe.height() > 0);
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_soa_xml_string(xml_str: &str) -> RSLifeResult<Self> {
        // Parse the XML string into SOAMortXML
        let xml_data = SOAMortXML::from_string(xml_str)
            .map_err(|e| PolarsError::ComputeError(e.to_string().into()))?;

        // Return error if the XML data is not in category of our filter
        if !is_soa_xml_data_approved(&xml_data) {
            return Err("XML data is not approved for calculation.".into());
        }

        // Create MortData from the first table in the XML
        let category = "SOA Mortality Data".to_string();
        let description = xml_data.content_classification.table_description.clone();
        let data = xml_data.tables[0].values.clone();
        let result = Self::new(category, description, data)?;
        Ok(result)
    }

    /// Parse mortality table from SOA XML file.
    ///
    /// Reads and parses an XML file containing SOA (Society of Actuaries)
    /// mortality table data. This is a convenience method that reads the file
    /// and delegates to `from_soa_xml_string()` for parsing.
    ///
    /// Only approved SOA table layouts and content types are accepted.
    /// See `from_soa_xml_string()` for more details on requirements.
    ///
    /// # Errors
    /// - File not found or not readable
    /// - File I/O errors
    /// - All errors from `from_soa_xml_string()`
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// // Load SOA mortality table from local XML file
    /// let mort_data = MortData::from_soa_xml_file_path_str("data/elt15_f.xml")?;
    /// assert_eq!(mort_data.category, "SOA Mortality Data");
    /// assert!(mort_data.dataframe.height() > 0);
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_soa_xml_file_path_str(file_path: &str) -> RSLifeResult<Self> {
        // Read the XML file into a string
        let xml_str = fs::read_to_string(file_path)
            .map_err(|e| PolarsError::ComputeError(e.to_string().into()))?;

        // Use the from_soa_xml_string method to create MortData
        Self::from_soa_xml_string(&xml_str)
    }

    /// Parse mortality table from SOA URL.
    ///
    /// Downloads and parses mortality table data directly from a SOA (Society of Actuaries)
    /// URL. This method makes an HTTP GET request to fetch XML data and then parses it.
    ///
    /// Requires internet connection and the URL must return valid SOA XML format.
    /// Only approved SOA table layouts and content types are accepted.
    ///
    /// # Errors
    /// - Network connectivity issues
    /// - HTTP request failures (4xx, 5xx status codes)
    /// - Invalid or unreachable URL
    /// - All errors from `from_soa_xml_string()`
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// // Load mortality table directly from SOA website
    /// let url = "https://mort.soa.org/data/t1704.xml";
    /// let mort_data = MortData::from_soa_url(url)?;
    /// assert!(!mort_data.category.is_empty());
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_soa_url(url: &str) -> RSLifeResult<Self> {
        // Fetch the XML data from the URL
        let response = reqwest::blocking::get(url)
            .map_err(|e| PolarsError::ComputeError(e.to_string().into()))?;

        if !response.status().is_success() {
            return Err("Failed to fetch XML data from URL".into());
        }

        let xml_str = response
            .text()
            .map_err(|e| PolarsError::ComputeError(e.to_string().into()))?;

        // Use the from_soa_xml_string method to create MortData
        Self::from_soa_xml_string(&xml_str)
    }

    /// Parse mortality table from SOA website by table ID.
    ///
    /// Convenience method to download mortality table data from the SOA website
    /// using just the table ID. Constructs the standard SOA URL format and
    /// delegates to `from_soa_url()`.
    ///
    /// The URL format used is: `https://mort.soa.org/data/t{id}.xml`
    ///
    /// Requires internet connection. Only approved SOA table layouts and content types are accepted.
    ///
    /// # Parameters
    /// - `id`: SOA table identifier (e.g., 1704 for table t1704.xml)
    ///
    /// # Errors
    /// - Invalid table ID (table does not exist)
    /// - All errors from `from_soa_url()`
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// // Load table t1704.xml from SOA website
    /// let mort_data = MortData::from_soa_url_id(1704)?;
    /// assert!(!mort_data.category.is_empty());
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_soa_url_id(id: i32) -> RSLifeResult<Self> {
        let url = format!("https://mort.soa.org/data/t{id}.xml");
        Self::from_soa_url(&url)
    }

    pub fn from_soa_custom(id: &str) -> RSLifeResult<Self> {
        match id {
            // Makeham law with A=0.00022, B=2.7e-6, C=1.124
            "SULT" => Self::from_Makeham_law()
                .A(0.00022)
                .B(2.7e-6)
                .C(1.124)
                .start_age(20)
                .call(),
            _ => Err(format!("Unknown SOA custom id: {id}").into()),
        }
    }

    // ========================================================
    // IFOA XLS  PARSING
    // ========================================================

    pub fn from_ifoa_xls_file_path_str(file_path: &str, sheet_name: &str) -> RSLifeResult<Self> {
        let data = IFOAMortXLS::from_xls_file_path_str(file_path, sheet_name)?;
        let result = Self::new(
            "IFOA Mortality Data".to_string(),
            data.description,
            data.dataframe,
        )?;
        Ok(result)
    }

    pub fn from_ifoa_url(url: &str) -> RSLifeResult<Self> {
        let data = IFOAMortXLS::from_url(url)?;
        let result = Self::new(
            "IFOA Mortality Data".to_string(),
            data.description,
            data.dataframe,
        )?;
        Ok(result)
    }

    pub fn from_ifoa_url_id(id: &str) -> RSLifeResult<Self> {
        let data = IFOAMortXLS::from_url_id(id)?;
        let result = Self::new(
            "IFOA Mortality Data".to_string(),
            data.description,
            data.dataframe,
        )?;
        Ok(result)
    }

    pub fn from_ifoa_custom(id: &str) -> RSLifeResult<Self> {
        let data = IFOAMortXLS::from_custom(id)?;
        let result = Self::new(
            "IFOA Mortality Data".to_string(),
            data.description,
            data.dataframe,
        )?;
        Ok(result)
    }

    // ========================================================
    // AUSTRALIAN GOVERNMENT ACTUARY  XLS  PARSING
    // ========================================================

    /// Parse mortality table from Australian Government Actuary XLS URL.
    ///
    /// Downloads and parses mortality table data directly from the Australian Government Actuary (AGA) website. This method makes an HTTP GET request to fetch XLS data, parses the file, and constructs a validated mortality table.
    ///
    /// The method requires specifying the gender and period for the table, which are used to construct the correct URL and select the appropriate sheet/data. The returned `MortData` is validated for schema and content.
    ///
    /// # Parameters
    /// - `gender`: Gender for the mortality table (e.g., "male", "female").
    /// - `period`: Period or year for the mortality table (e.g., "2015-17").
    ///
    /// # Errors
    /// - Network connectivity issues
    /// - HTTP request failures (4xx, 5xx status codes)
    /// - Invalid or unreachable URL
    /// - Invalid gender or period (sheet not found)
    /// - XLS parsing errors
    /// - Schema validation errors (via `new()`)
    ///
    /// # Examples
    /// ```rust, no_run
    /// # use rslife::prelude::*;
    /// // Download AGA mortality table for males, 2020-22 period
    /// let mort_data = MortData::from_aus_gov_act("male", "2020-22")?;
    /// assert!(!mort_data.category.is_empty());
    /// # RSLifeResult::Ok(())
    /// ```
    pub fn from_aus_gov_act(gender: &str, period: &str) -> RSLifeResult<Self> {
        let data = AusGovActMortXLS::from_url(gender, period)?;
        let result = Self::new(
            "Australian Government Actuarial Mortality Data".to_string(),
            data.description,
            data.dataframe,
        )?;
        Ok(result)
    }

    // ========================================================
    // OTHER PARSING METHODS
    // ========================================================

    /// Create mortality table from existing Polars DataFrame.
    ///
    /// Convenience method to create MortData from a pre-existing DataFrame
    /// with a default category name. The DataFrame must conform to mortality
    /// table schema requirements.
    ///
    /// This method delegates to `new()` with a standard category name,
    /// so all schema validation rules apply.
    ///
    /// # Schema Requirements
    /// - Must have 2 or 3 columns
    /// - First column: "age" (f64, but must contain whole numbers)
    /// - Second column: "qx" or "lx" (f64)
    /// - Optional third column: "duration" (f64, but must contain whole numbers)
    /// - All values must be non-negative
    /// - qx values must be ≤ 1.0
    ///
    /// # Errors
    /// - All errors from `new()` (schema validation failures)
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    /// use polars::prelude::*;
    ///
    /// let df = df! {
    ///     "age" => [25.0, 26.0, 27.0],
    ///     "qx" => [0.002, 0.003, 0.004]
    /// }?;
    ///
    /// let mort_data = MortData::from_df(df)?;
    /// assert_eq!(mort_data.category, "Custom Mortality Data");
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_df(df: DataFrame) -> RSLifeResult<Self> {
        // Create MortData with a default category
        let category = "Custom Mortality Data".to_string();
        let description = "Created from DataFrame".to_string();
        Self::new(category, description, df)
    }

    /// Parse mortality table from ODS file using spreadsheet-ods.
    ///
    /// Reads ODS files and automatically parses all columns as f64.
    /// Age and duration columns are validated to contain whole numbers during schema validation.
    ///
    /// Schema validation is performed by `from_df()` after parsing.
    ///
    /// # Errors
    /// - File not found or not readable
    /// - Invalid ODS format
    /// - Sheet not found
    /// - Invalid data in cells
    /// - Empty sheets or insufficient data
    /// - Schema validation errors (via `from_df`)
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// // Load mortality table from ODS file
    /// let mort_data = MortData::from_ods("data/test/ultimate_template.ods", "ultimate")?;
    /// assert_eq!(mort_data.category, "Custom Mortality Data");
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_ods(ods_file_path_str: &str, sheet_name: &str) -> RSLifeResult<Self> {
        // Open ODS workbook
        let workbook = read_ods(ods_file_path_str).map_err(|e| -> Box<dyn std::error::Error> {
            format!("Failed to open ODS file '{ods_file_path_str}': {e}").into()
        })?;

        // Find the sheet by name - iterate through sheets to find by name
        let mut sheet = None;
        for i in 0..workbook.num_sheets() {
            let current_sheet = workbook.sheet(i);
            if current_sheet.name() == sheet_name {
                sheet = Some(current_sheet);
                break;
            }
        }

        let sheet = sheet.ok_or_else(|| format!("Sheet '{sheet_name}' not found in ODS file"))?;

        // Check if sheet is empty
        let (max_row, _) = sheet.used_grid_size();
        if max_row < 1 {
            return Err(format!("Sheet '{sheet_name}' is empty").into());
        }

        // Extract headers
        let headers = parse_ods_headers(sheet, 0)?;

        // Extract all the data
        let data_cols = parse_ods_data(sheet, 1, headers.len())?;

        // Validate that we have data
        if data_cols.is_empty() || data_cols[0].is_empty() {
            return Err("No data rows found in sheet".into());
        }

        // Build DataFrame
        let mut columns = Vec::new();
        for (col_name, data_col) in headers.iter().zip(data_cols.iter()) {
            // Build Series directly as f64
            let series = Series::from_vec(col_name.into(), data_col.clone());
            columns.push(series.into_column());
        }
        let height = columns[0].len();
        let df = DataFrame::new(height, columns)
            .map_err(|e| format!("Failed to create DataFrame: {e}"))?;

        // Create MortData with a default category
        let category = "Custom Mortality Data".to_string();
        let description =
            "Created from ODS file {ods_file_path_str}, sheet {sheet_name}.".to_string();
        Self::new(category, description, df)
    }

    /// Parse mortality table from XLSX file using calamine.
    ///
    /// Reads XLSX files and automatically parses all columns as f64.
    /// Age and duration columns are validated to contain whole numbers during schema validation.
    ///
    /// Schema validation is performed by `from_df()` after parsing.
    ///
    /// # Errors
    /// - File not found or not readable
    /// - Invalid XLSX format
    /// - Sheet not found
    /// - Invalid data in cells
    /// - Empty sheets or insufficient data
    /// - Schema validation errors (via `from_df`)
    ///
    /// # Examples
    /// ```rust
    /// # use rslife::prelude::*;
    ///
    /// // Load mortality table from XLSX file
    /// let mort_data = MortData::from_xlsx("data/test/select_template.xlsx", "select")?;
    /// assert_eq!(mort_data.category, "Custom Mortality Data");
    ///# RSLifeResult::Ok(())
    /// ```
    pub fn from_xlsx(xlsx_file_path_str: &str, sheet_name: &str) -> RSLifeResult<Self> {
        // Open workbook
        let mut workbook = open_workbook_auto(xlsx_file_path_str)
            .map_err(|e| format!("Failed to open XLSX file '{xlsx_file_path_str}': {e}"))?;

        // Identify range
        let range = workbook
            .worksheet_range(sheet_name)
            .map_err(|e| format!("Failed to read sheet '{sheet_name}': {e}"))?;

        // Check if range is empty
        if range.is_empty() {
            return Err(format!("Sheet '{sheet_name}' is empty").into());
        }

        // Extract headers
        let headers = parse_excel_headers(&range, 0)?;

        // Extract all the data
        let data_cols = parse_excel_data(&range, 1, headers.len())?;

        // Validate that we have data
        if data_cols.is_empty() || data_cols[0].is_empty() {
            return Err("No data rows found in sheet".into());
        }

        // Build DataFrame
        let mut columns = Vec::new();
        for (col_name, data_col) in headers.iter().zip(data_cols.iter()) {
            // Build Series directly as f64
            let series = Series::from_vec(col_name.into(), data_col.clone());
            columns.push(series.into_column());
        }
        let height = columns[0].len();
        let df = DataFrame::new(height, columns)
            .map_err(|e| format!("Failed to create DataFrame: {e}"))?;

        // Create MortData with a default category
        let category = "Custom Mortality Data".to_string();
        let description =
            "Created from XLSX file {xlsx_file_path_str}, sheet {sheet_name}.".to_string();
        Self::new(category, description, df)
    }

    // ========================================================
    // COMMON PRELOADED TABLES (FxHashMap cache)
    // ========================================================

    /// Load a mortality table from the preloaded builtin cache.
    ///
    /// Tables are loaded once at first access and cached in an `FxHashMap`.
    /// Subsequent calls are O(1) lookups with no I/O overhead.
    ///
    /// # Supported IDs
    /// - IFOA: `AM92`, `AF92`, `PFA92`, `PMA92`
    /// - IFOA: `PFA92C10`, `PMA92C10`, `PFA92C20`, `PMA92C20`
    /// - SOA: `ELT15_F`, `ELT15_M`, `SULT`
    ///
    /// # Errors
    /// Returns an error if the id is not in the builtin cache.
    pub fn from_builtin(id: &str) -> RSLifeResult<Self> {
        super::builtin::BUILTIN_MORT_DATA
            .get(id)
            .cloned()
            .ok_or_else(|| format!("Builtin mortality table '{id}' not available").into())
    }
}

// ================================================
// PRIVATE FUNCTIONS
// ================================================

fn is_soa_xml_data_approved(data: &SOAMortXML) -> bool {
    // Check table layout
    let approved_table_layouts = ["Aggregate", "Ultimate", "Select", "Select & Ultimate"];
    let key_words = data.content_classification.key_words.clone();

    // Check if any keyword matches any approved table layout
    let tbl_layout_result = key_words.iter().any(|keyword| {
        approved_table_layouts
            .iter()
            .any(|layout| keyword == layout)
    });

    // Content type check
    let approved_content_types = vec![
        "ADB, AD&D",
        "Annuitant Mortality",
        "Claim Cost (in Disability)",
        "Claim Incidence",
        "Claim Termination",
        "CSO / CET",
        "Disability Recovery",
        "Disabled Lives Mortality",
        "Disability Incidence",
        "Group Life",
        "Healthy Lives Mortality",
        "Insured Lives Mortality",
        "Insured Lives Mortality - Ultimate",
        "Projection Scale",
        "Termination Voluntary",
        "Population Mortality",
    ];

    let content_type = data.content_classification.content_type.clone();

    // Check if content type is in approved content types
    let content_type_result = approved_content_types
        .iter()
        .any(|approved_type| content_type == *approved_type);

    // Return result
    tbl_layout_result && content_type_result
}

/// Validate DataFrame schema according to mortality table requirements.
///
/// Ensures DataFrame follows the expected schema for mortality tables:
/// - Must have 2 or 3 columns
/// - First column: "age" (f64, but must contain whole numbers)
/// - Second column: "qx" or "lx" (f64)
/// - Optional third column: "duration" (f64, but must contain whole numbers)
/// - All values must be non-negative
/// - qx values must be ≤ 1.0
/// - DataFrame must contain at least one row of data
///
/// This function performs comprehensive validation including:
/// - Row count validation (via `_validate_df_rows`)
/// - Column structure and type validation (via `_validate_df_columns`)
/// - Non-negative value validation (via `_validate_values_non_negative`)
/// - Mortality rate bounds validation (via `_validate_qx_lte_1`)
///
/// # Errors
/// - Empty DataFrame (no rows)
/// - Wrong number of columns (must be 2 or 3)
/// - Incorrect column names (must follow mortality table conventions)
/// - Incorrect data types (all columns must be f64)
/// - Invalid data values (negative values, qx > 1.0)
/// - Non-whole numbers in age/duration columns
fn validate_df_schema(df: &DataFrame) -> RSLifeResult<()> {
    // Check if DataFrame is empty (rows count)
    if df.height() == 0 {
        return Err("DataFrame must contain at least one row of data".into());
    }

    let columns = df.columns();
    let cols_count = columns.len();

    // Check column names using get_column_names
    let col_names = df.get_column_names();
    match cols_count {
        2 => {
            if !(col_names[0] == "age" && (col_names[1] == "qx" || col_names[1] == "lx")) {
                return Err("DataFrame columns must be ['age', 'qx/lx']".into());
            }
        }

        3 => {
            if !(col_names[0] == "age" && (col_names[1] == "qx")
                || (col_names[1] == "lx") && col_names[2] == "duration")
            {
                return Err("DataFrame columns must be ['age', 'qx/lx', 'duration']".into());
            }
        }

        _ => {
            return Err("DataFrame must have 2 or 3 columns".into());
        }
    }

    // Type input is flexible as long as:
    // - age and duration can be casted to u32
    // - qx/lx can be casted to f64
    for col in columns {
        let col_name = col.name();

        // qx <= 1.0
        if col_name == "qx" && col.f64().unwrap().max().unwrap_or(0.0) > 1.0 {
            return Err(format!("Column '{col_name}' must not exceed 1.0").into());
        }

        if col_name == "lx" || col_name == "qx" {
            // Check if column is f64 convertible
            if col.f64().is_err() {
                return Err(format!("Column '{col_name}' must be f64 convertible").into());
            }

            // f64 >=0
            if col.f64().unwrap().min().unwrap_or(0.0) < 0.0 {
                return Err(format!("Column '{col_name}' must be non-negative").into());
            }
        } else {
            // Check if column can be cast to u32 (age and duration)
            if col.cast(&DataType::UInt32).is_err() {
                return Err(format!("Column '{col_name}' must be u32 convertible").into());
            }

            // Filter unique values and casted to u32
            let unique_values = col.unique().unwrap();
            let mut values: Vec<u32> = unique_values
                .u32()
                .map(|ca| ca.into_no_null_iter().collect())
                .unwrap_or_default();

            // Sort ascending and check whether they are consecutive numbers
            values.sort_unstable();
            if values.len() > 1 {
                let is_consecutive = values
                    .iter()
                    .zip(values.iter().skip(1))
                    .all(|(a, b)| *b == *a + 1);
                if !is_consecutive {
                    return Err(format!(
                        "Column '{col_name}' must contain consecutive whole numbers (step 1)"
                    )
                    .into());
                }
            }
        }
    }

    Ok(())
}

fn keep_first_qx_1_remove_the_rest(ages: Vec<u32>, qx: Vec<f64>) -> RSLifeResult<DataFrame> {
    let mut found_one = false;
    let filtered: Vec<(u32, f64)> = ages
        .into_iter()
        .zip(qx)
        .filter(|&(_, rate)| {
            if rate == 1.0 {
                if !found_one {
                    found_one = true;
                    true
                } else {
                    false
                }
            } else {
                true
            }
        })
        .collect();
    let (ages, qx): (Vec<u32>, Vec<f64>) = filtered.into_iter().unzip();

    let data = df! {
        "age" => ages,
        "qx" => qx,
    }?;

    Ok(data)
}

fn setup_dataframe_to_correct_schema(df: DataFrame) -> PolarsResult<DataFrame> {
    // This function assumes DataFrame has already been validated
    // Validation is done in from_df() before calling this function

    let mut df = df.clone();

    // Cast age column to u32 if present
    if let Ok(age_col) = df.column("age") {
        let casted = age_col.cast(&DataType::UInt32)?;
        df.with_column(casted.into_column())?;
    }

    // Cast duration column to u32 if present
    if let Ok(duration_col) = df.column("duration") {
        let casted = duration_col.cast(&DataType::UInt32)?;
        df.with_column(casted.into_column())?;
    }

    Ok(df)
}

// =============================================================================
// UNIT TEST
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_from_soa_xml_file() {
        // Test loading from XML file in data directory
        let result = MortData::from_soa_xml_file_path_str("data/t1704.xml");

        match result {
            Ok(mort_data) => {
                println!("✓ Successfully loaded XML file: {}", mort_data.category);
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                // Verify basic structure
                assert!(mort_data.dataframe.height() > 0);
                assert!(mort_data.dataframe.columns().len() >= 2);

                // Check that we have age and qx/lx columns
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_rate = column_names
                    .iter()
                    .any(|name| name.as_str() == "qx" || name.as_str() == "lx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_rate, "Missing 'qx' or 'lx' column");

                // Display first few rows
                println!("  First 3 rows:\n{}", mort_data.dataframe.head(Some(3)));
            }
            Err(e) => {
                // If file doesn't exist or has issues, just verify the error is reasonable
                println!("XML file test failed (this may be expected): {e}");
                // Don't panic - file might not be available in all test environments
            }
        }
    }

    #[test]
    fn test_from_soa_url_id() {
        // Test loading from SOA URL by ID (table 1704)
        // Note: This test requires internet connection
        let result = MortData::from_soa_url_id(1704);

        match result {
            Ok(mort_data) => {
                println!(
                    "✓ Successfully loaded from SOA URL ID 1704: {}",
                    mort_data.category
                );
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                // Verify basic structure
                assert!(mort_data.dataframe.height() > 0);
                assert!(mort_data.dataframe.columns().len() >= 2);

                // Check that we have age and qx/lx columns
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_rate = column_names
                    .iter()
                    .any(|name| name.as_str() == "qx" || name.as_str() == "lx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_rate, "Missing 'qx' or 'lx' column");

                // Display first few rows
                println!("  First 3 rows:\n{}", mort_data.dataframe.head(Some(3)));
            }
            Err(e) => {
                // Network might not be available in test environment
                println!("SOA URL test failed (network may be unavailable): {e}");
                // Don't panic - network might not be available
            }
        }
    }

    #[test]
    fn test_from_soa_url() {
        // Test loading from full SOA URL
        let url = "https://mort.soa.org/data/t1704.xml";
        let result = MortData::from_soa_url(url);

        match result {
            Ok(mort_data) => {
                println!("✓ Successfully loaded from SOA URL: {}", mort_data.category);
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                // Verify basic structure
                assert!(mort_data.dataframe.height() > 0);
                assert!(mort_data.dataframe.columns().len() >= 2);

                // Check that we have age and qx/lx columns
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_rate = column_names
                    .iter()
                    .any(|name| name.as_str() == "qx" || name.as_str() == "lx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_rate, "Missing 'qx' or 'lx' column");

                // Display first few rows
                println!("  First 3 rows:\n{}", mort_data.dataframe.head(Some(3)));
            }
            Err(e) => {
                // Network might not be available in test environment
                println!("SOA URL test failed (network may be unavailable): {e}");
                // Don't panic - network might not be available
            }
        }
    }

    #[test]
    fn test_from_xlsx_file() {
        // Test loading from XLSX file in data directory
        let result = MortData::from_xlsx("data/test/select_template.xlsx", "select");

        match result {
            Ok(mort_data) => {
                println!("✓ Successfully loaded XLSX file: {}", mort_data.category);
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                // Verify basic structure
                assert!(mort_data.dataframe.height() > 0);
                assert!(mort_data.dataframe.columns().len() >= 2);

                // Check that we have age and qx/lx columns
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_rate = column_names
                    .iter()
                    .any(|name| name.as_str() == "qx" || name.as_str() == "lx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_rate, "Missing 'qx' or 'lx' column");

                // Display first few rows
                println!("  First 3 rows:\n{}", mort_data.dataframe.head(Some(3)));
            }
            Err(e) => {
                // Try alternative files if the first one doesn't work
                println!("First XLSX test failed, trying alternative: {e}");

                let alt_result = MortData::from_xlsx("data/elt15.xlsx", "elt15");
                match alt_result {
                    Ok(mort_data) => {
                        println!(
                            "✓ Successfully loaded alternative XLSX file: {}",
                            mort_data.category
                        );
                        println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                        // Verify basic structure
                        assert!(mort_data.dataframe.height() > 0);
                        assert!(mort_data.dataframe.columns().len() >= 2);
                    }
                    Err(e2) => {
                        println!("XLSX file tests failed (files may not be available): {e2}");
                        // Don't panic - files might not be available in all test environments
                    }
                }
            }
        }
    }

    #[test]
    fn test_from_ods_file() {
        // Test loading from ODS file in data directory
        let result = MortData::from_ods("data/test/ultimate_template.ods", "ultimate");

        match result {
            Ok(mort_data) => {
                println!("✓ Successfully loaded ODS file: {}", mort_data.category);
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                // Verify basic structure
                assert!(mort_data.dataframe.height() > 0);
                assert!(mort_data.dataframe.columns().len() >= 2);

                // Check that we have age and qx/lx columns
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_rate = column_names
                    .iter()
                    .any(|name| name.as_str() == "qx" || name.as_str() == "lx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_rate, "Missing 'qx' or 'lx' column");

                // Display first few rows
                println!("  First 3 rows:\n{}", mort_data.dataframe.head(Some(3)));
            }
            Err(e) => {
                // Try alternative files if the first one doesn't work
                println!("First ODS test failed, trying alternative: {e}");

                let alt_result = MortData::from_ods("data/elt15.ods", "elt15");
                match alt_result {
                    Ok(mort_data) => {
                        println!(
                            "✓ Successfully loaded alternative ODS file: {}",
                            mort_data.category
                        );
                        println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                        // Verify basic structure
                        assert!(mort_data.dataframe.height() > 0);
                        assert!(mort_data.dataframe.columns().len() >= 2);
                    }
                    Err(e2) => {
                        println!("ODS file tests failed (files may not be available): {e2}");
                        // Don't panic - files might not be available in all test environments
                    }
                }
            }
        }
    }

    #[test]
    fn test_from_df_basic() {
        // Test creating MortData from a basic DataFrame
        let df = df! {
            "age" => [20.0, 21.0, 22.0, 23.0, 24.0],
            "qx" => [0.001, 0.002, 0.003, 0.004, 0.005]
        }
        .expect("Failed to create test DataFrame");

        let result = MortData::from_df(df);

        match result {
            Ok(mort_data) => {
                println!(
                    "✓ Successfully created MortData from DataFrame: {}",
                    mort_data.category
                );
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                assert_eq!(mort_data.dataframe.height(), 5);
                assert_eq!(mort_data.dataframe.width(), 2);
                assert_eq!(mort_data.category, "Custom Mortality Data");

                // Check column names
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_qx = column_names.iter().any(|name| name.as_str() == "qx");
                assert!(has_age, "Missing 'age' column");
                assert!(has_qx, "Missing 'qx' column");

                println!("  DataFrame:\n{}", mort_data.dataframe);
            }
            Err(e) => {
                panic!("DataFrame creation should not fail: {e}");
            }
        }
    }

    #[test]
    fn test_from_df_with_duration() {
        // Test creating MortData from a DataFrame with duration column (select table)
        let df = df! {
            "age" => [25.0, 25.0, 26.0, 26.0],
            "qx" => [0.001, 0.002, 0.002, 0.003],
            "duration" => [0.0, 1.0, 0.0, 1.0]
        }
        .expect("Failed to create test DataFrame with duration");

        let result = MortData::from_df(df);

        match result {
            Ok(mort_data) => {
                println!(
                    "✓ Successfully created MortData with duration from DataFrame: {}",
                    mort_data.category
                );
                println!("  DataFrame shape: {:?}", mort_data.dataframe.shape());

                assert_eq!(mort_data.dataframe.height(), 4);
                assert_eq!(mort_data.dataframe.width(), 3);

                // Check column names
                let column_names = mort_data.dataframe.get_column_names();
                let has_age = column_names.iter().any(|name| name.as_str() == "age");
                let has_qx = column_names.iter().any(|name| name.as_str() == "qx");
                let has_duration = column_names.iter().any(|name| name.as_str() == "duration");
                assert!(has_age, "Missing 'age' column");
                assert!(has_qx, "Missing 'qx' column");
                assert!(has_duration, "Missing 'duration' column");

                println!("  DataFrame:\n{}", mort_data.dataframe);
            }
            Err(e) => {
                panic!("DataFrame with duration creation should not fail: {e}");
            }
        }
    }

    #[test]
    fn test_validation_errors() {
        // Test that validation catches common errors

        // Test 1: Invalid column names
        let invalid_df = df! {
            "invalid_age" => [20.0, 21.0],
            "invalid_rate" => [0.001, 0.002]
        }
        .expect("Failed to create invalid test DataFrame");

        let result = MortData::from_df(invalid_df);
        assert!(result.is_err(), "Should fail with invalid column names");
        println!("✓ Correctly rejected DataFrame with invalid column names");

        // Test 2: qx values > 1.0
        let invalid_qx_df = df! {
            "age" => [20.0, 21.0],
            "qx" => [0.5, 1.5]  // 1.5 > 1.0, should fail
        }
        .expect("Failed to create invalid qx test DataFrame");

        let result2 = MortData::from_df(invalid_qx_df);
        assert!(result2.is_err(), "Should fail with qx > 1.0");
        println!("✓ Correctly rejected DataFrame with qx > 1.0");

        // Test 3: Negative values
        let negative_df = df! {
            "age" => [20.0, 21.0],
            "qx" => [-0.001, 0.002]  // Negative qx should fail
        }
        .expect("Failed to create negative test DataFrame");

        let result3 = MortData::from_df(negative_df);
        assert!(result3.is_err(), "Should fail with negative values");
        println!("✓ Correctly rejected DataFrame with negative values");
    }
}