Skip to main content

oxigeo_dev_tools/
generator.rs

1//! Test data generation utilities
2//!
3//! This module provides tools for generating test data for OxiGeo operations.
4
5use crate::{DevToolsError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9/// Test data generator
10pub struct DataGenerator {
11    /// Random seed
12    seed: u64,
13}
14
15impl DataGenerator {
16    /// Create a new data generator
17    pub fn new() -> Self {
18        Self { seed: 12345 }
19    }
20
21    /// Create a generator with a specific seed
22    pub fn with_seed(seed: u64) -> Self {
23        Self { seed }
24    }
25
26    /// Generate raster data
27    pub fn generate_raster(&self, width: usize, height: usize, pattern: RasterPattern) -> Vec<f64> {
28        let mut data = vec![0.0; width * height];
29
30        match pattern {
31            RasterPattern::Flat(value) => {
32                data.fill(value);
33            }
34            RasterPattern::Gradient {
35                from,
36                to,
37                direction,
38            } => {
39                for y in 0..height {
40                    for x in 0..width {
41                        let t = match direction {
42                            GradientDirection::Horizontal => x as f64 / (width - 1) as f64,
43                            GradientDirection::Vertical => y as f64 / (height - 1) as f64,
44                            GradientDirection::Diagonal => {
45                                ((x + y) as f64) / ((width + height - 2) as f64)
46                            }
47                        };
48                        data[y * width + x] = from + (to - from) * t;
49                    }
50                }
51            }
52            RasterPattern::Checkerboard {
53                size,
54                color1,
55                color2,
56            } => {
57                for y in 0..height {
58                    for x in 0..width {
59                        let is_odd = ((x / size) + (y / size)) % 2 == 1;
60                        data[y * width + x] = if is_odd { color1 } else { color2 };
61                    }
62                }
63            }
64            RasterPattern::Noise { min, max } => {
65                for (i, item) in data.iter_mut().enumerate() {
66                    *item = min + (max - min) * self.pseudo_random(i);
67                }
68            }
69            RasterPattern::Sine {
70                amplitude,
71                frequency,
72            } => {
73                use std::f64::consts::PI;
74                for y in 0..height {
75                    for x in 0..width {
76                        let phase = 2.0 * PI * frequency * (x as f64 / width as f64);
77                        data[y * width + x] = amplitude * phase.sin();
78                    }
79                }
80            }
81        }
82
83        data
84    }
85
86    /// Simple pseudo-random number generator (LCG)
87    fn pseudo_random(&self, index: usize) -> f64 {
88        let a = 1103515245u64;
89        let c = 12345u64;
90        let m = 2u64.pow(31);
91
92        let x = ((a
93            .wrapping_mul(self.seed.wrapping_add(index as u64))
94            .wrapping_add(c))
95            % m) as f64;
96        x / m as f64
97    }
98
99    /// Generate vector features (points)
100    pub fn generate_points(&self, count: usize, bounds: Bounds) -> Vec<Point> {
101        let mut points = Vec::with_capacity(count);
102
103        for i in 0..count {
104            let x = bounds.min_x + (bounds.max_x - bounds.min_x) * self.pseudo_random(i * 2);
105            let y = bounds.min_y + (bounds.max_y - bounds.min_y) * self.pseudo_random(i * 2 + 1);
106
107            points.push(Point { x, y });
108        }
109
110        points
111    }
112
113    /// Generate regular grid of points.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`crate::DevToolsError::Generator`] if `rows == 0` or
118    /// `cols == 0` (there is no meaningful grid with zero rows/columns).
119    ///
120    /// When `rows == 1` or `cols == 1`, the single row/column is placed at
121    /// `bounds`'s minimum instead of dividing by `(rows - 1)` or
122    /// `(cols - 1)` (which previously underflowed to `0` and produced
123    /// `+inf`/`NaN` point coordinates silently for every point in that row
124    /// or column).
125    pub fn generate_grid(&self, rows: usize, cols: usize, bounds: Bounds) -> Result<Vec<Point>> {
126        if rows == 0 || cols == 0 {
127            return Err(DevToolsError::Generator(format!(
128                "generate_grid requires rows >= 1 and cols >= 1, got rows={rows}, cols={cols}"
129            )));
130        }
131
132        let mut points = Vec::with_capacity(rows * cols);
133
134        let dx = if cols > 1 {
135            (bounds.max_x - bounds.min_x) / (cols - 1) as f64
136        } else {
137            0.0
138        };
139        let dy = if rows > 1 {
140            (bounds.max_y - bounds.min_y) / (rows - 1) as f64
141        } else {
142            0.0
143        };
144
145        for row in 0..rows {
146            for col in 0..cols {
147                let x = bounds.min_x + col as f64 * dx;
148                let y = bounds.min_y + row as f64 * dy;
149                points.push(Point { x, y });
150            }
151        }
152
153        Ok(points)
154    }
155}
156
157impl Default for DataGenerator {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Raster pattern
164#[derive(Debug, Clone)]
165pub enum RasterPattern {
166    /// Flat value
167    Flat(f64),
168    /// Gradient
169    Gradient {
170        /// Start value
171        from: f64,
172        /// End value
173        to: f64,
174        /// Direction
175        direction: GradientDirection,
176    },
177    /// Checkerboard pattern
178    Checkerboard {
179        /// Cell size
180        size: usize,
181        /// Color 1
182        color1: f64,
183        /// Color 2
184        color2: f64,
185    },
186    /// Random noise
187    Noise {
188        /// Minimum value
189        min: f64,
190        /// Maximum value
191        max: f64,
192    },
193    /// Sine wave
194    Sine {
195        /// Amplitude
196        amplitude: f64,
197        /// Frequency
198        frequency: f64,
199    },
200}
201
202/// Gradient direction
203#[derive(Debug, Clone, Copy)]
204pub enum GradientDirection {
205    /// Horizontal (left to right)
206    Horizontal,
207    /// Vertical (top to bottom)
208    Vertical,
209    /// Diagonal (top-left to bottom-right)
210    Diagonal,
211}
212
213/// 2D point
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct Point {
216    /// X coordinate
217    pub x: f64,
218    /// Y coordinate
219    pub y: f64,
220}
221
222/// Bounding box
223#[derive(Debug, Clone, Copy)]
224pub struct Bounds {
225    /// Minimum X
226    pub min_x: f64,
227    /// Minimum Y
228    pub min_y: f64,
229    /// Maximum X
230    pub max_x: f64,
231    /// Maximum Y
232    pub max_y: f64,
233}
234
235impl Bounds {
236    /// Create new bounds
237    pub fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
238        Self {
239            min_x,
240            min_y,
241            max_x,
242            max_y,
243        }
244    }
245}
246
247/// File generator for creating test files
248pub struct FileGenerator;
249
250impl FileGenerator {
251    /// Generate a minimal valid single-band Float32 GeoTIFF with WGS84 georeferencing.
252    ///
253    /// The file is written using the `oxigeo-geotiff` driver so it is a fully
254    /// conformant TIFF/GeoTIFF that can be re-opened by any compliant reader.
255    ///
256    /// # Arguments
257    /// * `path`   – Destination file path.
258    /// * `width`  – Image width in pixels (must be ≥ 1).
259    /// * `height` – Image height in pixels (must be ≥ 1).
260    ///
261    /// The georeferencing covers a small WGS84 bounding box centred on the
262    /// prime meridian / equator (lon 0..1°, lat 1..0° — north-up).
263    pub fn generate_geotiff(path: &Path, width: usize, height: usize) -> Result<()> {
264        use oxigeo_core::types::{GeoTransform, RasterDataType};
265        use oxigeo_geotiff::tiff::Compression;
266        use oxigeo_geotiff::writer::{GeoTiffWriter, GeoTiffWriterOptions, WriterConfig};
267
268        if width == 0 || height == 0 {
269            return Err(crate::DevToolsError::Generator(
270                "width and height must be >= 1".to_string(),
271            ));
272        }
273
274        // Build a simple gradient raster (Float32, 1 band).
275        // Values increase linearly from 0.0 at top-left to 1.0 at bottom-right.
276        let pixel_count = width * height;
277        let max_idx = (pixel_count - 1) as f32;
278        let float_data: Vec<f32> = (0..pixel_count)
279            .map(|i| i as f32 / max_idx.max(1.0))
280            .collect();
281
282        // Re-interpret as raw bytes for the writer (little-endian f32).
283        let raw: Vec<u8> = float_data.iter().flat_map(|v| v.to_le_bytes()).collect();
284
285        // WGS84 bounding box: upper-left (0°E, 1°N), lower-right (1°E, 0°N).
286        // pixel_width  =  1.0 / width  degrees per pixel  (west→east)
287        // pixel_height = -1.0 / height degrees per pixel  (north→south, negative)
288        let pixel_width = 1.0_f64 / width as f64;
289        let pixel_height = -1.0_f64 / height as f64;
290        let geo_transform = GeoTransform::new(
291            0.0,          // origin_x  (upper-left longitude)
292            pixel_width,  // pixel_width
293            0.0,          // row_rotation (north-up → 0)
294            1.0,          // origin_y  (upper-left latitude)
295            0.0,          // col_rotation (north-up → 0)
296            pixel_height, // pixel_height (negative = north-up)
297        );
298
299        let config = WriterConfig::new(
300            width as u64,
301            height as u64,
302            1, // single band
303            RasterDataType::Float32,
304        )
305        .with_compression(Compression::Lzw)
306        .with_geo_transform(geo_transform)
307        .with_epsg_code(4326); // WGS84 geographic CRS
308
309        let mut writer = GeoTiffWriter::create(path, config, GeoTiffWriterOptions::default())?;
310        writer.write(&raw)?;
311
312        Ok(())
313    }
314
315    /// Generate a simple GeoJSON file
316    pub fn generate_geojson(path: &Path, points: &[Point]) -> Result<()> {
317        use std::io::Write;
318
319        let mut geojson =
320            String::from("{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n");
321
322        for (i, point) in points.iter().enumerate() {
323            geojson.push_str(&format!(
324                "    {{\n      \"type\": \"Feature\",\n      \"geometry\": {{\n        \"type\": \"Point\",\n        \"coordinates\": [{}, {}]\n      }},\n      \"properties\": {{\n        \"id\": {}\n      }}\n    }}",
325                point.x, point.y, i
326            ));
327
328            if i < points.len() - 1 {
329                geojson.push_str(",\n");
330            } else {
331                geojson.push('\n');
332            }
333        }
334
335        geojson.push_str("  ]\n}");
336
337        let mut file = std::fs::File::create(path)?;
338        file.write_all(geojson.as_bytes())?;
339
340        Ok(())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use std::sync::atomic::{AtomicU64, Ordering};
348
349    /// Per-test scratch fixture inside the system temp dir (house policy: no
350    /// hardcoded absolute paths).
351    ///
352    /// The leaf name embeds the process id and a monotonic counter, so no two
353    /// test binaries — nor two concurrent runs of this one — can ever land on
354    /// the same file.  Dropping the guard removes the fixture, so a panicking
355    /// test leaks nothing.
356    struct TempPath(std::path::PathBuf);
357
358    impl TempPath {
359        fn new(name: &str) -> Self {
360            static COUNTER: AtomicU64 = AtomicU64::new(0);
361            let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
362            Self(std::env::temp_dir().join(format!(
363                "oxigeo_devtools_{}_{seq}_{name}",
364                std::process::id()
365            )))
366        }
367    }
368
369    impl std::ops::Deref for TempPath {
370        type Target = std::path::Path;
371
372        fn deref(&self) -> &std::path::Path {
373            &self.0
374        }
375    }
376
377    impl AsRef<std::path::Path> for TempPath {
378        fn as_ref(&self) -> &std::path::Path {
379            &self.0
380        }
381    }
382
383    impl Drop for TempPath {
384        fn drop(&mut self) {
385            let _ = std::fs::remove_file(&self.0);
386        }
387    }
388
389    #[test]
390    fn test_generator_creation() {
391        let generator = DataGenerator::new();
392        assert_eq!(generator.seed, 12345);
393    }
394
395    #[test]
396    fn test_generate_flat_raster() {
397        let generator = DataGenerator::new();
398        let data = generator.generate_raster(10, 10, RasterPattern::Flat(42.0));
399        assert_eq!(data.len(), 100);
400        assert!(data.iter().all(|&v| v == 42.0));
401    }
402
403    #[test]
404    fn test_generate_gradient_raster() {
405        let generator = DataGenerator::new();
406        let data = generator.generate_raster(
407            10,
408            10,
409            RasterPattern::Gradient {
410                from: 0.0,
411                to: 100.0,
412                direction: GradientDirection::Horizontal,
413            },
414        );
415        assert_eq!(data.len(), 100);
416        assert_eq!(data[0], 0.0); // First column
417        assert!((data[9] - 100.0).abs() < 0.01); // Last column
418    }
419
420    #[test]
421    fn test_generate_checkerboard() {
422        let generator = DataGenerator::new();
423        let data = generator.generate_raster(
424            10,
425            10,
426            RasterPattern::Checkerboard {
427                size: 5,
428                color1: 0.0,
429                color2: 100.0,
430            },
431        );
432        assert_eq!(data.len(), 100);
433    }
434
435    #[test]
436    fn test_generate_noise() {
437        let generator = DataGenerator::new();
438        let data = generator.generate_raster(
439            10,
440            10,
441            RasterPattern::Noise {
442                min: 0.0,
443                max: 100.0,
444            },
445        );
446        assert_eq!(data.len(), 100);
447        assert!(data.iter().all(|&v| (0.0..=100.0).contains(&v)));
448    }
449
450    #[test]
451    fn test_generate_points() {
452        let generator = DataGenerator::new();
453        let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
454        let points = generator.generate_points(10, bounds);
455        assert_eq!(points.len(), 10);
456        assert!(points.iter().all(|p| p.x >= 0.0 && p.x <= 100.0));
457        assert!(points.iter().all(|p| p.y >= 0.0 && p.y <= 100.0));
458    }
459
460    #[test]
461    fn test_generate_grid() {
462        let generator = DataGenerator::new();
463        let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
464        let points = generator
465            .generate_grid(5, 5, bounds)
466            .expect("5x5 grid should succeed");
467        assert_eq!(points.len(), 25);
468        assert!(points.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
469    }
470
471    #[test]
472    fn test_generate_grid_single_column_does_not_produce_nan_or_inf() {
473        let generator = DataGenerator::new();
474        let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
475        let points = generator
476            .generate_grid(5, 1, bounds)
477            .expect("single-column grid should succeed, not divide by zero");
478        assert_eq!(points.len(), 5);
479        assert!(
480            points.iter().all(|p| p.x.is_finite() && p.y.is_finite()),
481            "single-column grid must not produce NaN/inf coordinates: {points:?}"
482        );
483        // The single column is placed at the bounds' minimum X.
484        assert!(points.iter().all(|p| (p.x - 0.0).abs() < 1e-9));
485    }
486
487    #[test]
488    fn test_generate_grid_single_row_does_not_produce_nan_or_inf() {
489        let generator = DataGenerator::new();
490        let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
491        let points = generator
492            .generate_grid(1, 5, bounds)
493            .expect("single-row grid should succeed, not divide by zero");
494        assert_eq!(points.len(), 5);
495        assert!(
496            points.iter().all(|p| p.x.is_finite() && p.y.is_finite()),
497            "single-row grid must not produce NaN/inf coordinates: {points:?}"
498        );
499        assert!(points.iter().all(|p| (p.y - 0.0).abs() < 1e-9));
500    }
501
502    #[test]
503    fn test_generate_grid_single_point() {
504        let generator = DataGenerator::new();
505        let bounds = Bounds::new(10.0, 20.0, 100.0, 100.0);
506        let points = generator
507            .generate_grid(1, 1, bounds)
508            .expect("1x1 grid should succeed");
509        assert_eq!(points.len(), 1);
510        assert!(points[0].x.is_finite() && points[0].y.is_finite());
511        assert!((points[0].x - 10.0).abs() < 1e-9);
512        assert!((points[0].y - 20.0).abs() < 1e-9);
513    }
514
515    #[test]
516    fn test_generate_grid_zero_rows_or_cols_errors_instead_of_panicking() {
517        let generator = DataGenerator::new();
518        let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0);
519        assert!(generator.generate_grid(0, 5, bounds).is_err());
520        assert!(generator.generate_grid(5, 0, bounds).is_err());
521        assert!(generator.generate_grid(0, 0, bounds).is_err());
522    }
523
524    #[test]
525    fn test_generate_geojson() -> Result<()> {
526        use tempfile::NamedTempFile;
527
528        let temp_file = NamedTempFile::new()?;
529        let points = vec![Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }];
530
531        FileGenerator::generate_geojson(temp_file.path(), &points)?;
532
533        let content = std::fs::read_to_string(temp_file.path())?;
534        assert!(content.contains("FeatureCollection"));
535        assert!(content.contains("Point"));
536
537        Ok(())
538    }
539
540    #[test]
541    fn test_generate_geotiff_creates_nonempty_file() -> Result<()> {
542        let path = TempPath::new("nonempty.tif");
543
544        FileGenerator::generate_geotiff(&path, 32, 32)?;
545
546        let metadata = std::fs::metadata(&path)?;
547        assert!(metadata.is_file(), "output must be a regular file");
548        assert!(metadata.len() > 0, "generated GeoTIFF must be non-empty");
549
550        // Verify TIFF magic bytes (little-endian: II + 42 or II + 43 for BigTIFF).
551        let bytes = std::fs::read(&path)?;
552        assert!(bytes.len() >= 4, "file too short to contain a TIFF header");
553        let is_tiff_le = bytes[0] == b'I'
554            && bytes[1] == b'I'
555            && bytes[3] == 0
556            && (bytes[2] == 42 || bytes[2] == 43); // 42 = classic TIFF, 43 = BigTIFF
557        let is_tiff_be = bytes[0] == b'M' && bytes[1] == b'M' && (bytes[3] == 42 || bytes[3] == 43);
558        assert!(
559            is_tiff_le || is_tiff_be,
560            "file does not start with a valid TIFF magic sequence"
561        );
562
563        Ok(())
564    }
565
566    #[test]
567    fn test_generate_geotiff_rejects_zero_dimensions() {
568        let path = TempPath::new("zero.tif");
569        assert!(
570            FileGenerator::generate_geotiff(&path, 0, 16).is_err(),
571            "zero width should return an error"
572        );
573        assert!(
574            FileGenerator::generate_geotiff(&path, 16, 0).is_err(),
575            "zero height should return an error"
576        );
577    }
578}