Research-Stack/scripts/csv_to_parquet.py
Brandon Schneider e5fb0a5f4d chore: commit accumulated working tree changes
Lean: update Semantics modules, add new numerics/physics data files
Hardware: update FPGA bitstreams (tangnano9k_uart_loopback)
Infra: k3s-flake tests, netcup-vps configuration, VCN compute substrate
Docs: ARCHITECTURE, specs, citation updates
2026-05-30 00:10:02 -05:00

179 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""
csv_to_parquet.py — Convert 50 years of HEPData CSVs to Parquet format
Input: /tmp/hepdata-bulk/ (49 records, 642 CSV files)
Output: /tmp/hepdata-parquet/ (consolidated Parquet files)
Parquet advantages:
- Columnar storage (10x faster reads for analytics)
- Compression (typically 3-10x smaller)
- Schema enforcement
- Predicate pushdown for filtering
Usage:
python3 csv_to_parquet.py
"""
import os
import json
import glob
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from pathlib import Path
import re
import sys
def parse_hepdata_csv(filepath):
"""Parse a HEPData CSV file into a DataFrame."""
rows = []
metadata = {}
current_table = None
current_observable = None
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
# Extract metadata from comments
if line.startswith('#:'):
parts = line[2:].split(':', 1)
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip()
if key == 'table_doi':
metadata['doi'] = value
elif key == 'name':
metadata['table_name'] = value
elif key == 'description':
metadata['description'] = value
continue
# Skip empty lines and pure comment headers
if not line or line.startswith('#'):
continue
# Try to parse as CSV
parts = line.split(',')
# Check if this looks like data (first element is numeric)
try:
first_val = float(parts[0])
# This is a data row
row = {
'source_file': os.path.basename(filepath),
'row_data': line
}
# Extract observable name from previous header if available
if current_observable:
row['observable'] = current_observable
rows.append(row)
except ValueError:
# This might be a header line with observable name
# Check for patterns like $P'_{5}$ or $F_{\rm L}$
if '$' in line:
# Extract observable name
match = re.search(r'\$([^$]+)\$', line)
if match:
current_observable = match.group(1)
# Clean up LaTeX
current_observable = current_observable.replace('\\rm ', '')
current_observable = current_observable.replace('\\', '')
return rows, metadata
def process_record(record_dir):
"""Process all CSV files in a HEPData record directory."""
record_name = os.path.basename(record_dir)
record_id = record_name.replace('HEPData-', '').replace('-csv', '')
all_rows = []
for csv_file in sorted(Path(record_dir).glob("*.csv")):
rows, metadata = parse_hepdata_csv(str(csv_file))
for row in rows:
row['record_id'] = record_id
row['record_name'] = record_name
row['table_file'] = csv_file.name
row['doi'] = metadata.get('doi', '')
row['table_name'] = metadata.get('table_name', '')
row['description'] = metadata.get('description', '')
all_rows.extend(rows)
return all_rows
def main():
bulk_dir = Path("/tmp/hepdata-bulk")
output_dir = Path("/tmp/hepdata-parquet")
output_dir.mkdir(exist_ok=True)
print("Scanning HEPData records...", file=sys.stderr)
all_rows = []
record_count = 0
for record_dir in sorted(bulk_dir.iterdir()):
if record_dir.is_dir() and record_dir.name.startswith("HEPData-"):
rows = process_record(record_dir)
all_rows.extend(rows)
record_count += 1
if record_count % 10 == 0:
print(f" Processed {record_count} records, {len(all_rows)} rows...", file=sys.stderr)
print(f"Total: {record_count} records, {len(all_rows)} rows", file=sys.stderr)
if not all_rows:
print("No data found!", file=sys.stderr)
sys.exit(1)
# Create DataFrame
df = pd.DataFrame(all_rows)
# Add derived columns
df['record_index'] = df.index
# Write to Parquet (partitioned by record_id for efficient queries)
print("Writing Parquet files...", file=sys.stderr)
# Single file for small dataset
single_path = output_dir / "hepdata_all.parquet"
df.to_parquet(single_path, engine='pyarrow', compression='snappy')
print(f" Single file: {single_path} ({os.path.getsize(single_path) / 1024:.1f} KB)", file=sys.stderr)
# Partitioned by record (for large-scale queries)
partitioned_path = output_dir / "partitioned"
df.to_parquet(partitioned_path, engine='pyarrow', compression='snappy',
partition_cols=['record_id'])
print(f" Partitioned: {partitioned_path}", file=sys.stderr)
# Summary statistics
summary = {
'total_records': record_count,
'total_rows': len(df),
'unique_records': df['record_id'].nunique(),
'unique_tables': df['source_file'].nunique(),
'columns': list(df.columns),
'file_size_mb': os.path.getsize(single_path) / (1024 * 1024),
'parquet_file': str(single_path),
'partitioned_dir': str(partitioned_path)
}
# Save summary
summary_path = output_dir / "summary.json"
with open(summary_path, 'w') as f:
json.dump(summary, f, indent=2)
print(f"\nSummary:", file=sys.stderr)
print(f" Records: {summary['total_records']}", file=sys.stderr)
print(f" Rows: {summary['total_rows']}", file=sys.stderr)
print(f" File size: {summary['file_size_mb']:.2f} MB", file=sys.stderr)
# Print summary to stdout
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()