heisenberg_data_processing/processed/
mod.rs

1use std::path::Path;
2
3use polars::prelude::*;
4use tracing::{info, info_span};
5
6use super::error::Result;
7use crate::raw::fetch::TempData;
8
9pub mod create_admin_search;
10pub mod create_place_search;
11
12/// Generate processed data from raw sources and save to parquet files
13pub fn generate_processed_data(temp_data: TempData) -> Result<(DataFrame, DataFrame)> {
14    // Cant drop temp files until end of scope otherwise they get deleted and the lazy frames
15    // cant read the data.
16    let mut dfs = {
17        let _span = info_span!("Transform Raw Data").entered();
18
19        let admin_search_lf = create_admin_search::get_admin_search_lf(&temp_data);
20
21        let place_search_lf =
22            create_place_search::get_place_search_lf(&temp_data, admin_search_lf.clone());
23        info!("Collecting transformed data");
24        let transform_time = std::time::Instant::now();
25        let dfs = collect_all([admin_search_lf, place_search_lf])?;
26        info!(
27            transform_time = ?transform_time.elapsed()
28            , "Transforming data took"
29        );
30        dfs
31    };
32
33    let place_search_df = dfs.pop().expect("Place search should be last");
34    let admin_search_df = dfs.pop().expect("Admin search should be first");
35
36    drop(temp_data); // Drop temp data to clean up files
37
38    Ok((admin_search_df, place_search_df))
39}
40
41pub fn save_processed_data_to_parquet(df: DataFrame, path: &Path) -> Result<()> {
42    let sink_time = std::time::Instant::now();
43
44    let mut df = df
45        .lazy()
46        .drop_nulls(col("geonameId").into_selector())
47        .sort(["geonameId"], SortMultipleOptions::default())
48        .collect()?;
49    let mut file = std::fs::File::create(path)?;
50    ParquetWriter::new(&mut file).finish(&mut df)?;
51
52    info!(
53        path = ?path.file_stem(),
54        sink_time = ?sink_time.elapsed(),
55        "Saved to parquet file"
56    );
57    Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::tests_utils::*;
64
65    #[test]
66    fn test_create_admin_search_actual_transformation() {
67        // Use the actual test data from samples
68        let temp_data = crate::raw::test_data::create_test_data();
69
70        // Test the actual transformation
71        let result = create_admin_search::get_admin_search_lf(&temp_data);
72        let df = result.collect().unwrap();
73
74        // Test that the transformation worked
75        assert!(df.height() > 0, "Admin search should have results");
76
77        // Test that admin_level was computed correctly
78        assert_has_columns(&df, &["admin_level"]);
79        assert_column_type(&df, "admin_level", &DataType::UInt8);
80
81        // Test that only admin levels < 5 are included (as per your filter)
82        let admin_levels: Vec<Option<u8>> = df
83            .column("admin_level")
84            .unwrap()
85            .u8()
86            .unwrap()
87            .into_iter()
88            .collect();
89        for level in admin_levels.iter().flatten() {
90            assert!(*level < 5, "All admin levels should be < 5, found: {level}");
91        }
92
93        // Test that country-level (PCLI) and state-level (ADM1) entries are present
94        let feature_codes: Vec<Option<&str>> = df
95            .column("feature_code")
96            .unwrap()
97            .str()
98            .unwrap()
99            .into_iter()
100            .collect();
101        assert!(
102            feature_codes.contains(&Some("PCLI")),
103            "Should contain country-level entity"
104        );
105        assert!(
106            feature_codes.contains(&Some("ADM1")),
107            "Should contain state-level entity"
108        );
109    }
110
111    #[test]
112    fn test_create_place_search_actual_transformation() {
113        // Use the actual test data from samples
114        let temp_data = crate::raw::test_data::create_test_data();
115
116        // Debug: Check input data
117        let all_countries_df = temp_data.places.as_lazy_frame().collect().unwrap();
118        println!(
119            "All countries data (before anti-join): {} rows",
120            all_countries_df.height()
121        );
122
123        // Create admin search
124        let admin_search_lf = create_admin_search::get_admin_search_lf(&temp_data);
125        let admin_search_df = admin_search_lf.clone().collect().unwrap();
126        println!("Admin search data: {} rows", admin_search_df.height());
127
128        // Debug: Test the anti-join directly
129        let after_anti_join = temp_data
130            .places
131            .as_lazy_frame()
132            .join(
133                admin_search_lf.clone(),
134                [col("geonameId")],
135                [col("geonameId")],
136                JoinArgs {
137                    how: JoinType::Anti,
138                    ..Default::default()
139                },
140            )
141            .collect()
142            .unwrap();
143        println!("After anti-join: {} rows", after_anti_join.height());
144
145        // Debug: Test the filter step
146        let after_filter = temp_data
147            .places
148            .as_lazy_frame()
149            .join(
150                admin_search_lf.clone(),
151                [col("geonameId")],
152                [col("geonameId")],
153                JoinArgs {
154                    how: JoinType::Anti,
155                    ..Default::default()
156                },
157            )
158            .filter(
159                (col("admin0_code").is_not_null()).and(
160                    col("feature_class").is_in(
161                        lit(Series::new(
162                            "feature_class_to_keep".into(),
163                            ["P", "S", "T", "H", "L", "V", "R"],
164                        ))
165                        .implode(),
166                        false,
167                    ),
168                ),
169            )
170            .collect()
171            .unwrap();
172        println!("After filter: {} rows", after_filter.height());
173        println!("After filter data: {after_filter:?}");
174
175        // Debug: Check if feature_codes parsing is working
176        let feature_codes_df = temp_data.feature_codes.as_lazy_frame().collect().unwrap();
177        println!("Feature codes data: {} rows", feature_codes_df.height());
178        println!("Feature codes: {feature_codes_df:?}");
179
180        // Debug the actual transformation - run it but collect intermediate steps
181        let result = create_place_search::get_place_search_lf(&temp_data, admin_search_lf);
182
183        let df = result.collect().unwrap();
184        println!("Final result: {} rows", df.height());
185        if df.height() > 0 {
186            println!("Final data: {df:?}");
187        }
188
189        // Test that transformation worked
190        assert!(df.height() > 0, "Place search should have results");
191
192        // Test required columns exist
193        assert_has_columns(&df, &["importance_score", "importance_tier"]);
194        assert_column_type(&df, "importance_score", &DataType::Float64);
195        assert_column_type(&df, "importance_tier", &DataType::UInt8);
196
197        // Test that importance values are in valid ranges
198        assert_column_range(&df, "importance_score", 0.0f64, 1.0f64);
199        assert_column_range(&df, "importance_tier", 1u8, 5u8);
200
201        // Test that places (P class) are included, admin (A class) are excluded
202        let feature_classes: Vec<Option<&str>> = df
203            .column("feature_class")
204            .unwrap()
205            .str()
206            .unwrap()
207            .into_iter()
208            .collect();
209        assert!(
210            feature_classes.contains(&Some("P")),
211            "Should contain places"
212        );
213        assert!(
214            !feature_classes.contains(&Some("A")),
215            "Should not contain admin entities after anti-join"
216        );
217    }
218}