use std::collections::HashMap;
use std::error::Error;
use std::path::{Path, PathBuf};
use glob::Pattern;
use polars::prelude::*;
use rayon::prelude::*;
use walkdir::WalkDir;
use crate::convert::common;
#[derive(Debug, Clone)]
pub struct ConcatConfig {
pub pattern: String,
pub renumber: bool,
pub sort_by_pres: bool,
pub drop_na_pres: bool,
pub threads: Option<usize>,
}
impl Default for ConcatConfig {
fn default() -> Self {
ConcatConfig {
pattern: "*.parquet".to_string(),
renumber: true,
sort_by_pres: true,
drop_na_pres: true,
threads: None,
}
}
}
fn filter_valid_pres(lf: LazyFrame) -> LazyFrame {
lf.filter(col("pres").is_not_null().and(col("pres").is_not_nan()))
}
fn temp_path_for(output: &Path, i: usize) -> PathBuf {
let name = output
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("concat_output");
output.with_file_name(format!("{}.concat-tmp-{:05}", name, i))
}
fn build_range_df(
spans: &[(PathBuf, String, String)],
lo: &str,
hi: &str,
config: &ConcatConfig,
) -> Result<Option<DataFrame>, Box<dyn Error>> {
let mut acc: Option<DataFrame> = None;
for (path, file_min, file_max) in spans {
if file_max.as_str() < lo || file_min.as_str() > hi {
continue;
}
let part = LazyFrame::scan_parquet(path, ScanArgsParquet::default())?
.filter(
col("platform_code")
.gt_eq(lit(lo))
.and(col("platform_code").lt_eq(lit(hi))),
)
.collect()
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
if part.height() == 0 {
continue;
}
acc = Some(match acc {
Some(a) => a.vstack(&part)?,
None => part,
});
}
let Some(frame) = acc else { return Ok(None) };
let mut lf = frame.lazy();
if config.drop_na_pres {
lf = filter_valid_pres(lf);
}
let df = renumber(lf, config.sort_by_pres).collect()?;
if df.height() == 0 {
return Ok(None);
}
Ok(Some(df))
}
fn build_and_write_range(
spans: &[(PathBuf, String, String)],
lo: &str,
hi: &str,
config: &ConcatConfig,
temp_path: &Path,
) -> Result<(), String> {
match build_range_df(spans, lo, hi, config).map_err(|e| e.to_string())? {
None => Ok(()),
Some(mut df) => {
let f = std::fs::File::create(temp_path)
.map_err(|e| format!("Cannot create temp file {}: {}", temp_path.display(), e))?;
ParquetWriter::new(f)
.set_parallel(false)
.finish(&mut df)
.map_err(|e| e.to_string())?;
Ok(())
}
}
}
fn collect_parquet_files(src_dir: &Path, pattern: &str) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let glob_pat = Pattern::new(pattern)
.map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
let mut files: Vec<PathBuf> = WalkDir::new(src_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map_or(false, |name| glob_pat.matches(name))
})
.map(|e| e.path().to_path_buf())
.collect();
files.sort();
Ok(files)
}
fn renumber(lf: LazyFrame, sort_by_pres: bool) -> LazyFrame {
let mut sort_keys: Vec<&str> =
vec!["platform_code", "profile_timestamp", "longitude", "latitude"];
if sort_by_pres {
sort_keys.push("pres");
}
let sort_opts = SortMultipleOptions::default().with_maintain_order(!sort_by_pres);
lf.sort(sort_keys, sort_opts)
.with_column(
concat_str(
[
col("platform_code"),
col("profile_timestamp").cast(DataType::String),
col("longitude").cast(DataType::String),
col("latitude").cast(DataType::String),
],
"|",
false,
)
.alias("_profile_key"),
)
.with_column(
col("_profile_key")
.rank(
RankOptions {
method: RankMethod::Dense,
descending: false,
},
None,
)
.over(["platform_code"])
.cast(DataType::UInt32)
.alias("profile_no"),
)
.with_column(
col("platform_code")
.cum_count(false)
.over(["platform_code", "profile_no"])
.cast(DataType::UInt32)
.alias("observation_no"),
)
.drop(["_profile_key"])
}
struct PlatformIndex {
counts: HashMap<String, u64>,
spans: Vec<(PathBuf, String, String)>,
}
fn scan_platform_index(files: &[PathBuf]) -> Result<PlatformIndex, Box<dyn Error>> {
let mut counts: HashMap<String, u64> = HashMap::new();
let mut spans: Vec<(PathBuf, String, String)> = Vec::new();
for path in files {
let df = LazyFrame::scan_parquet(path, ScanArgsParquet::default())
.map_err(|e| format!("Cannot scan {}: {}", path.display(), e))?
.select([col("platform_code")])
.group_by([col("platform_code")])
.agg([len().alias("n")])
.collect()
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
let pc = df.column("platform_code")?.str()?;
let n = df.column("n")?.cast(&DataType::UInt64)?;
let n = n.u64()?;
let mut file_min: Option<String> = None;
let mut file_max: Option<String> = None;
for (name, cnt) in pc.into_iter().zip(n.into_iter()) {
let (name, cnt) = (name.unwrap(), cnt.unwrap());
*counts.entry(name.to_string()).or_insert(0) += cnt;
if file_min.as_deref().map_or(true, |m| name < m) {
file_min = Some(name.to_string());
}
if file_max.as_deref().map_or(true, |m| name > m) {
file_max = Some(name.to_string());
}
}
if let (Some(lo), Some(hi)) = (file_min, file_max) {
spans.push((path.clone(), lo, hi));
}
}
Ok(PlatformIndex { counts, spans })
}
fn partition_platform_ranges(
counts: &HashMap<String, u64>,
budget: u64,
) -> Vec<(String, String)> {
let mut platforms: Vec<&String> = counts.keys().collect();
platforms.sort();
let mut ranges = Vec::new();
let mut i = 0;
while i < platforms.len() {
let lo = platforms[i].clone();
let mut sum = counts[platforms[i]];
let mut j = i;
while j + 1 < platforms.len() && sum + counts[platforms[j + 1]] <= budget {
j += 1;
sum += counts[platforms[j]];
}
ranges.push((lo, platforms[j].clone()));
i = j + 1;
}
ranges
}
pub fn run_concat_parquet(
src_dir: &Path,
output_file: &Path,
config: &ConcatConfig,
) -> Result<usize, Box<dyn Error>> {
let files = collect_parquet_files(src_dir, &config.pattern)?;
let n = files.len();
if files.is_empty() {
eprintln!(
"No files matching '{}' under {}; no output written.",
config.pattern,
src_dir.display()
);
return Ok(0);
}
let n_threads = match config.threads {
Some(n) => n.max(1),
None => std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1),
};
let parallel = n_threads > 1;
if parallel && std::env::var_os("POLARS_MAX_THREADS").is_none() {
std::env::set_var("POLARS_MAX_THREADS", "1");
}
if let Some(parent) = output_file.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create output directory: {}", e))?;
}
}
let empty = LazyFrame::scan_parquet(&files[0], ScanArgsParquet::default())?
.limit(0)
.collect()?;
let empty = if config.renumber {
renumber(empty.lazy(), config.sort_by_pres).collect()?
} else {
empty
};
let schema = empty.schema();
if !config.renumber {
let out_file = std::fs::File::create(output_file)
.map_err(|e| format!("Cannot create {}: {}", output_file.display(), e))?;
let mut writer = ParquetWriter::new(out_file).set_parallel(false).batched(&schema)?;
for path in &files {
let mut lf = LazyFrame::scan_parquet(path, ScanArgsParquet::default())?;
if config.drop_na_pres {
lf = filter_valid_pres(lf);
}
let df = lf
.collect()
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
writer.write_batch(&df)?;
}
writer.finish()?;
return Ok(n);
}
let index = scan_platform_index(&files)?;
let ranges = partition_platform_ranges(&index.counts, common::chunk_rows() as u64);
if parallel {
if !ranges.is_empty() {
let temp_paths: Vec<PathBuf> =
(0..ranges.len()).map(|i| temp_path_for(output_file, i)).collect();
let work = || -> Vec<String> {
ranges
.par_iter()
.zip(temp_paths.par_iter())
.filter_map(|((lo, hi), tmp)| {
build_and_write_range(&index.spans, lo, hi, config, tmp).err()
})
.collect()
};
const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024;
let errors = rayon::ThreadPoolBuilder::new()
.stack_size(WORKER_STACK_SIZE)
.num_threads(n_threads)
.build()
.map_err(|e| format!("Failed to build thread pool: {}", e))?
.install(work);
if !errors.is_empty() {
for tmp in &temp_paths {
let _ = std::fs::remove_file(tmp);
}
return Err(format!("Concat processing errors:\n{}", errors.join("\n")).into());
}
let out_file = std::fs::File::create(output_file)
.map_err(|e| format!("Cannot create {}: {}", output_file.display(), e))?;
let mut writer = ParquetWriter::new(out_file).set_parallel(false).batched(&schema)?;
let mut wrote_any = false;
for tmp in &temp_paths {
if !tmp.exists() {
continue; }
let df = LazyFrame::scan_parquet(tmp, ScanArgsParquet::default())?
.collect()
.map_err(|e| format!("Cannot read temp file {}: {}", tmp.display(), e))?;
if df.height() > 0 {
writer.write_batch(&df)?;
wrote_any = true;
}
}
if !wrote_any {
writer.write_batch(&empty)?;
}
writer.finish()?;
for tmp in &temp_paths {
let _ = std::fs::remove_file(tmp);
}
return Ok(n);
}
}
let out_file = std::fs::File::create(output_file)
.map_err(|e| format!("Cannot create {}: {}", output_file.display(), e))?;
let mut writer = ParquetWriter::new(out_file).set_parallel(false).batched(&schema)?;
if ranges.is_empty() {
writer.write_batch(&empty)?;
writer.finish()?;
return Ok(n);
}
for (lo, hi) in &ranges {
if let Some(df) = build_range_df(&index.spans, lo, hi, config)? {
writer.write_batch(&df)?;
}
}
writer.finish()?;
Ok(n)
}
pub fn run_concat_header(
src_dir: &Path,
output_file: &Path,
pattern: &str,
) -> Result<usize, Box<dyn Error>> {
let files = collect_parquet_files(src_dir, pattern)?;
let n = files.len();
if files.is_empty() {
eprintln!(
"No files matching '{}' under {}; no output written.",
pattern,
src_dir.display()
);
return Ok(0);
}
let mut combined: HashMap<String, serde_yaml::Value> = HashMap::new();
let mut duplicates: Vec<String> = Vec::new();
for file in &files {
let content = std::fs::read_to_string(file)
.map_err(|e| format!("Cannot read {}: {}", file.display(), e))?;
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&content)
.map_err(|e| format!("Cannot parse {}: {}", file.display(), e))?;
for (key, value) in map {
if combined.contains_key(&key) {
duplicates.push(format!(" key '{}' in {}", key, file.display()));
} else {
combined.insert(key, value);
}
}
}
if !duplicates.is_empty() {
return Err(format!(
"Duplicate keys detected in YAML files:\n{}",
duplicates.join("\n")
)
.into());
}
if let Some(parent) = output_file.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create output directory: {}", e))?;
}
}
let out_file = std::fs::File::create(output_file)
.map_err(|e| format!("Cannot create {}: {}", output_file.display(), e))?;
serde_yaml::to_writer(out_file, &combined)?;
Ok(n)
}