1use crate::cell::HexCell;
2use crate::coord::{ConversionMethod, Crs};
3use crate::error::N3gbError;
4use crate::geom::parse_geometry;
5use std::collections::{HashMap, HashSet};
6use std::fs::File;
7use std::path::Path;
8
9enum SourceIndices {
10 Geometry(usize),
11 Coordinates { x_idx: usize, y_idx: usize },
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum GeometryFormat {
16 Wkt,
18 GeoJson,
20}
21
22#[derive(Debug, Clone)]
23pub enum CoordinateSource {
24 GeometryColumn(String),
26 CoordinateColumns { x_column: String, y_column: String },
28}
29
30#[derive(Debug, Clone)]
32pub struct CsvHexConfig {
33 pub source: CoordinateSource,
34 pub exclude_columns: Vec<String>,
35 pub zoom_level: u8,
36 pub crs: Crs,
37 pub include_hex_geometry: Option<GeometryFormat>,
38 pub hex_density: bool,
39 pub conversion_method: ConversionMethod,
40}
41
42impl CsvHexConfig {
43 pub fn new(geometry_column: impl Into<String>, zoom_level: u8) -> Self {
59 Self {
60 source: CoordinateSource::GeometryColumn(geometry_column.into()),
61 exclude_columns: Vec::new(),
62 zoom_level,
63 crs: Crs::default(),
64 include_hex_geometry: None,
65 hex_density: false,
66 conversion_method: ConversionMethod::default(),
67 }
68 }
69
70 pub fn from_coords(
93 x_column: impl Into<String>,
94 y_column: impl Into<String>,
95 zoom_level: u8,
96 ) -> Self {
97 Self {
98 source: CoordinateSource::CoordinateColumns {
99 x_column: x_column.into(),
100 y_column: y_column.into(),
101 },
102 exclude_columns: Vec::new(),
103 zoom_level,
104 crs: Crs::default(),
105 include_hex_geometry: None,
106 hex_density: false,
107 conversion_method: ConversionMethod::default(),
108 }
109 }
110
111 pub fn exclude(mut self, columns: Vec<String>) -> Self {
119 self.exclude_columns = columns;
120 self
121 }
122
123 pub fn crs(mut self, crs: Crs) -> Self {
131 self.crs = crs;
132 self
133 }
134
135 pub fn with_hex_geometry(mut self, format: GeometryFormat) -> Self {
143 self.include_hex_geometry = Some(format);
144 self
145 }
146
147 pub fn conversion_method(mut self, method: ConversionMethod) -> Self {
157 self.conversion_method = method;
158 self
159 }
160
161 pub fn hex_density(mut self) -> Self {
169 self.hex_density = true;
170 self
171 }
172}
173
174fn read_cells_from_record(
190 record: &csv::StringRecord,
191 source_indices: &SourceIndices,
192 config: &CsvHexConfig,
193) -> Result<Vec<HexCell>, N3gbError> {
194 match source_indices {
195 SourceIndices::Geometry(idx) => {
196 let geom_str = record.get(*idx).ok_or_else(|| {
197 N3gbError::CsvError(format!("Missing geometry column at index {}", idx))
198 })?;
199 let geom = parse_geometry(geom_str)?;
200 match HexCell::from_geometry(
201 geom,
202 config.zoom_level,
203 config.crs,
204 config.conversion_method,
205 ) {
206 Ok(cells) => Ok(cells),
207 Err(N3gbError::ProjectionError(_)) => Ok(vec![]),
208 Err(e) => Err(e),
209 }
210 }
211 SourceIndices::Coordinates { x_idx, y_idx } => {
212 let x_str = record
213 .get(*x_idx)
214 .ok_or_else(|| N3gbError::CsvError(format!("Missing X column at index {}", x_idx)))?
215 .trim();
216 let y_str = record
217 .get(*y_idx)
218 .ok_or_else(|| N3gbError::CsvError(format!("Missing Y column at index {}", y_idx)))?
219 .trim();
220
221 let x: f64 = x_str
222 .parse()
223 .map_err(|_| N3gbError::CsvError(format!("Invalid X coordinate: '{}'", x_str)))?;
224 let y: f64 = y_str
225 .parse()
226 .map_err(|_| N3gbError::CsvError(format!("Invalid Y coordinate: '{}'", y_str)))?;
227
228 use crate::coord::convert_to_bng;
229 let cell = match config.crs {
230 Crs::Wgs84 => match convert_to_bng(&(x, y), config.conversion_method) {
231 Ok(bng) => HexCell::from_bng(&bng, config.zoom_level)?,
232 Err(N3gbError::ProjectionError(_)) => return Ok(vec![]),
233 Err(e) => return Err(e),
234 },
235 Crs::Bng => HexCell::from_bng(&(x, y), config.zoom_level)?,
236 };
237 Ok(vec![cell])
238 }
239 }
240}
241
242fn csv_to_hex_density(
259 mut reader: csv::Reader<File>,
260 source_indices: SourceIndices,
261 output_path: impl AsRef<Path>,
262 config: &CsvHexConfig,
263) -> Result<(), N3gbError> {
264 let mut counts: HashMap<String, usize> = HashMap::new();
265
266 for result in reader.records() {
267 let record = result?;
268 let cells = read_cells_from_record(&record, &source_indices, config)?;
269
270 for cell in cells {
271 *counts.entry(cell.id).or_insert(0) += 1;
272 }
273 }
274
275 let mut sorted: Vec<_> = counts.into_iter().collect();
276 sorted.sort_by(|a, b| b.1.cmp(&a.1));
277
278 let out_file = File::create(output_path)?;
279 let mut writer = csv::Writer::from_writer(out_file);
280
281 let mut header_row: Vec<&str> = vec!["hex_id", "count"];
282 if config.include_hex_geometry.is_some() {
283 header_row.push("hex_geometry");
284 }
285 writer.write_record(&header_row)?;
286
287 for (hex_id, count) in &sorted {
288 let mut row: Vec<String> = vec![hex_id.clone(), count.to_string()];
289
290 if let Some(format) = config.include_hex_geometry {
291 let cell = HexCell::from_hex_id(hex_id)?;
292 let polygon = cell.to_polygon();
293 let geom_str = match format {
294 GeometryFormat::Wkt => polygon_to_wkt(&polygon),
295 GeometryFormat::GeoJson => polygon_to_geojson(&polygon),
296 };
297 row.push(geom_str);
298 }
299
300 writer.write_record(&row)?;
301 }
302
303 writer.flush()?;
304
305 Ok(())
306}
307
308fn polygon_to_wkt(polygon: &geo_types::Polygon<f64>) -> String {
316 use wkt::ToWkt;
317 polygon.wkt_string()
318}
319
320fn polygon_to_geojson(polygon: &geo_types::Polygon<f64>) -> String {
328 let geom = geojson::Geometry::from(polygon);
329 geom.to_string()
330}
331
332pub fn csv_to_hex_csv(
376 csv_path: impl AsRef<Path>,
377 output_path: impl AsRef<Path>,
378 config: &CsvHexConfig,
379) -> Result<(), N3gbError> {
380 let file = File::open(csv_path)?;
381 let mut reader = csv::Reader::from_reader(file);
382
383 let headers = reader.headers()?.clone();
384
385 let (source_indices, mut exclude_indices) =
388 match &config.source {
389 CoordinateSource::GeometryColumn(col) => {
390 if col.is_empty() {
391 return Err(N3gbError::CsvError(
392 "Geometry column name cannot be empty".to_string(),
393 ));
394 }
395 let idx = headers.iter().position(|h| h == col).ok_or_else(|| {
396 N3gbError::CsvError(format!("Geometry column '{}' not found", col))
397 })?;
398 let mut exclude = HashSet::new();
399 exclude.insert(idx);
400 (SourceIndices::Geometry(idx), exclude)
401 }
402 CoordinateSource::CoordinateColumns { x_column, y_column } => {
403 if x_column.is_empty() {
404 return Err(N3gbError::CsvError(
405 "X column name cannot be empty".to_string(),
406 ));
407 }
408 if y_column.is_empty() {
409 return Err(N3gbError::CsvError(
410 "Y column name cannot be empty".to_string(),
411 ));
412 }
413 let x_idx = headers.iter().position(|h| h == x_column).ok_or_else(|| {
414 N3gbError::CsvError(format!("X column '{}' not found", x_column))
415 })?;
416 let y_idx = headers.iter().position(|h| h == y_column).ok_or_else(|| {
417 N3gbError::CsvError(format!("Y column '{}' not found", y_column))
418 })?;
419 let mut exclude = HashSet::new();
420 exclude.insert(x_idx);
421 exclude.insert(y_idx);
422 (SourceIndices::Coordinates { x_idx, y_idx }, exclude)
423 }
424 };
425
426 for col_name in &config.exclude_columns {
427 if let Some(idx) = headers.iter().position(|h| h == col_name) {
428 exclude_indices.insert(idx);
429 }
430 }
431
432 if config.hex_density {
433 return csv_to_hex_density(reader, source_indices, output_path, config);
434 }
435
436 let out_file = File::create(output_path)?;
437 let mut writer = csv::Writer::from_writer(out_file);
438
439 let mut header_row: Vec<&str> = vec!["hex_id"];
440 if config.include_hex_geometry.is_some() {
441 header_row.push("hex_geometry");
442 }
443 for (i, h) in headers.iter().enumerate() {
444 if !exclude_indices.contains(&i) {
445 header_row.push(h);
446 }
447 }
448 writer.write_record(&header_row)?;
449
450 for result in reader.records() {
451 let record = result?;
452
453 let cells = read_cells_from_record(&record, &source_indices, config)?;
454
455 for cell in cells {
456 let mut row: Vec<String> = vec![cell.id.clone()];
457
458 if let Some(format) = config.include_hex_geometry {
459 let polygon = cell.to_polygon();
460 let geom_str = match format {
461 GeometryFormat::Wkt => polygon_to_wkt(&polygon),
462 GeometryFormat::GeoJson => polygon_to_geojson(&polygon),
463 };
464 row.push(geom_str);
465 }
466
467 for (i, field) in record.iter().enumerate() {
468 if !exclude_indices.contains(&i) {
469 row.push(field.to_string());
470 }
471 }
472 writer.write_record(&row)?;
473 }
474 }
475
476 writer.flush()?;
477
478 Ok(())
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use std::io::Write;
485 use tempfile::tempdir;
486
487 #[test]
488 fn test_csv_to_hex_csv_wgs84() -> Result<(), N3gbError> {
489 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
490 let csv_path = dir.path().join("test.csv");
491 let output_path = dir.path().join("output.csv");
492
493 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
494 writeln!(file, "ASSET_ID,TYPE,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
495 writeln!(
496 file,
497 "CDT123,Pipe,\"{{\"\"type\"\":\"\"Point\"\",\"\"coordinates\"\":[-0.1,51.5]}}\""
498 )
499 .map_err(|e| N3gbError::IoError(e.to_string()))?;
500
501 let config = CsvHexConfig::new("geometry", 12).crs(Crs::Wgs84);
502 csv_to_hex_csv(&csv_path, &output_path, &config)?;
503
504 assert!(output_path.exists());
505 Ok(())
506 }
507
508 #[test]
509 fn test_csv_to_hex_csv_bng() -> Result<(), N3gbError> {
510 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
511 let csv_path = dir.path().join("test.csv");
512 let output_path = dir.path().join("output.csv");
513
514 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
516 writeln!(file, "ASSET_ID,TYPE,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
517 writeln!(file, "CDT123,Pipe,\"POINT(530000 180000)\"")
518 .map_err(|e| N3gbError::IoError(e.to_string()))?;
519
520 let config = CsvHexConfig::new("geometry", 12).crs(Crs::Bng);
521 csv_to_hex_csv(&csv_path, &output_path, &config)?;
522
523 assert!(output_path.exists());
524 Ok(())
525 }
526
527 #[test]
528 fn test_csv_from_coords_bng() -> Result<(), N3gbError> {
529 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
530 let csv_path = dir.path().join("test.csv");
531 let output_path = dir.path().join("output.csv");
532
533 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
534 writeln!(file, "StopCode,Name,Easting,Northing")
535 .map_err(|e| N3gbError::IoError(e.to_string()))?;
536 writeln!(file, "ABC123,Temple Meads,359581,172304")
537 .map_err(|e| N3gbError::IoError(e.to_string()))?;
538 writeln!(file, "DEF456,Castle Park,358500,173100")
539 .map_err(|e| N3gbError::IoError(e.to_string()))?;
540
541 let config = CsvHexConfig::from_coords("Easting", "Northing", 12).crs(Crs::Bng);
542 csv_to_hex_csv(&csv_path, &output_path, &config)?;
543
544 assert!(output_path.exists());
545
546 let output =
547 std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
548 assert!(output.contains("hex_id"));
549 assert!(output.contains("StopCode"));
550 assert!(output.contains("Name"));
551 assert!(!output.contains(",Easting,"));
552 assert!(!output.contains(",Northing"));
553
554 Ok(())
555 }
556
557 #[test]
558 fn test_csv_hex_density() -> Result<(), N3gbError> {
559 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
560 let csv_path = dir.path().join("test.csv");
561 let output_path = dir.path().join("output.csv");
562
563 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
564 writeln!(file, "StopCode,Name,Easting,Northing")
565 .map_err(|e| N3gbError::IoError(e.to_string()))?;
566
567 writeln!(file, "ABC123,Stop A,359581,172304")
568 .map_err(|e| N3gbError::IoError(e.to_string()))?;
569 writeln!(file, "DEF456,Stop B,359582,172305")
570 .map_err(|e| N3gbError::IoError(e.to_string()))?;
571
572 writeln!(file, "GHI789,Stop C,350000,170000")
573 .map_err(|e| N3gbError::IoError(e.to_string()))?;
574
575 let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
576 .crs(Crs::Bng)
577 .hex_density();
578 csv_to_hex_csv(&csv_path, &output_path, &config)?;
579
580 let output =
581 std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
582 let lines: Vec<&str> = output.lines().collect();
583
584 assert_eq!(lines[0], "hex_id,count");
585 assert_eq!(lines.len(), 3);
586
587 assert!(lines[1].ends_with(",2"));
588 assert!(lines[2].ends_with(",1"));
589
590 assert!(!output.contains("StopCode"));
591 assert!(!output.contains("Name"));
592
593 Ok(())
594 }
595
596 #[test]
597 fn test_csv_hex_density_with_geometry() -> Result<(), N3gbError> {
598 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
599 let csv_path = dir.path().join("test.csv");
600 let output_path = dir.path().join("output.csv");
601
602 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
603 writeln!(file, "ID,Easting,Northing").map_err(|e| N3gbError::IoError(e.to_string()))?;
604 writeln!(file, "1,359581,172304").map_err(|e| N3gbError::IoError(e.to_string()))?;
605 writeln!(file, "2,359582,172305").map_err(|e| N3gbError::IoError(e.to_string()))?;
606
607 let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
608 .crs(Crs::Bng)
609 .hex_density()
610 .with_hex_geometry(GeometryFormat::Wkt);
611 csv_to_hex_csv(&csv_path, &output_path, &config)?;
612
613 let output =
614 std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
615 let lines: Vec<&str> = output.lines().collect();
616
617 assert_eq!(lines[0], "hex_id,count,hex_geometry");
618 assert!(lines[1].contains("POLYGON"));
619 Ok(())
620 }
621
622 #[test]
623 fn test_csv_hex_density_wgs84() -> Result<(), N3gbError> {
624 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
625 let csv_path = dir.path().join("test.csv");
626 let output_path = dir.path().join("output.csv");
627
628 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
629 writeln!(file, "ID,Lon,Lat").map_err(|e| N3gbError::IoError(e.to_string()))?;
630 writeln!(file, "1,-2.583,51.448").map_err(|e| N3gbError::IoError(e.to_string()))?;
632 writeln!(file, "2,-2.583,51.448").map_err(|e| N3gbError::IoError(e.to_string()))?;
633 writeln!(file, "3,-1.500,53.800").map_err(|e| N3gbError::IoError(e.to_string()))?;
634
635 let config = CsvHexConfig::from_coords("Lon", "Lat", 8)
636 .crs(Crs::Wgs84)
637 .hex_density();
638 csv_to_hex_csv(&csv_path, &output_path, &config)?;
639
640 let output =
641 std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
642 let lines: Vec<&str> = output.lines().collect();
643
644 assert_eq!(lines[0], "hex_id,count");
646 assert_eq!(lines.len(), 3);
647 assert!(lines[1].ends_with(",2"));
649 assert!(lines[2].ends_with(",1"));
650 Ok(())
651 }
652
653 #[test]
654 fn test_csv_hex_density_geometry_column() -> Result<(), N3gbError> {
655 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
656 let csv_path = dir.path().join("test.csv");
657 let output_path = dir.path().join("output.csv");
658
659 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
660 writeln!(file, "ID,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
661 writeln!(file, "1,POINT(530000 180000)").map_err(|e| N3gbError::IoError(e.to_string()))?;
662 writeln!(file, "2,POINT(530001 180001)").map_err(|e| N3gbError::IoError(e.to_string()))?;
663 writeln!(file, "3,POINT(400000 300000)").map_err(|e| N3gbError::IoError(e.to_string()))?;
664
665 let config = CsvHexConfig::new("geometry", 10)
666 .crs(Crs::Bng)
667 .hex_density();
668 csv_to_hex_csv(&csv_path, &output_path, &config)?;
669
670 let output =
671 std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
672 let lines: Vec<&str> = output.lines().collect();
673
674 assert_eq!(lines[0], "hex_id,count");
675 assert_eq!(lines.len(), 3);
677 assert!(lines[1].ends_with(",2"));
678 assert!(lines[2].ends_with(",1"));
679 Ok(())
680 }
681
682 #[test]
683 fn test_csv_from_coords_wgs84() -> Result<(), N3gbError> {
684 let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
685 let csv_path = dir.path().join("test.csv");
686 let output_path = dir.path().join("output.csv");
687
688 let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
689 writeln!(file, "ID,Longitude,Latitude,Description")
690 .map_err(|e| N3gbError::IoError(e.to_string()))?;
691 writeln!(file, "1,-2.58302,51.44827,Bristol Temple Meads")
692 .map_err(|e| N3gbError::IoError(e.to_string()))?;
693
694 let config = CsvHexConfig::from_coords("Longitude", "Latitude", 12).crs(Crs::Wgs84);
695 csv_to_hex_csv(&csv_path, &output_path, &config)?;
696
697 assert!(output_path.exists());
698 Ok(())
699 }
700}