diff --git a/README.md b/README.md index 9a20ea0..b3871da 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,10 @@ geotessera download [OPTIONS] Options: -o, --output PATH Output directory [required] - --bbox TEXT Bounding box: 'min_lon,min_lat,max_lon,max_lat' + --bbox TEXT Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat' + --tile TEXT Single tile by any point within it: 'lon,lat' --region-file PATH GeoJSON/Shapefile to define region + --country TEXT Country name (e.g., 'United Kingdom', 'UK', 'GB') -f, --format TEXT Output format: 'tiff' or 'npy' (default: tiff) --year INT Year of embeddings (default: 2024) --bands TEXT Comma-separated band indices (default: all 128) @@ -335,6 +337,15 @@ Options: -v, --verbose Verbose output ``` +Single tile examples: +```bash +# Download a single tile containing a specific point +geotessera download --tile "0.17,52.23" --year 2024 -o ./single_tile + +# Same result using --bbox with 2 coordinates +geotessera download --bbox "0.17,52.23" --year 2024 -o ./single_tile +``` + Output formats: - **tiff**: Georeferenced GeoTIFF files with UTM projection - **npy**: Raw numpy arrays with metadata.json file @@ -366,12 +377,14 @@ geotessera coverage [OPTIONS] Options: -o, --output PATH Output PNG file (default: tessera_coverage.png) --year INT Specific year to visualize + --bbox TEXT Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat' + --tile TEXT Single tile by any point within it: 'lon,lat' + --region-file PATH GeoJSON/Shapefile to focus on specific region + --country TEXT Country name to focus on (e.g., 'United Kingdom') --tile-color TEXT Color for tiles (default: red) --tile-alpha FLOAT Transparency 0-1 (default: 0.6) --tile-size FLOAT Size multiplier (default: 1.0) - --dpi INT Output resolution (default: 100) - --width INT Figure width in inches (default: 20) - --height INT Figure height in inches (default: 10) + --width INT Output image width in pixels (default: 2000) --no-countries Don't show country boundaries ``` diff --git a/geotessera/cli.py b/geotessera/cli.py index 725b5d7..e07a42b 100644 --- a/geotessera/cli.py +++ b/geotessera/cli.py @@ -26,6 +26,7 @@ from geotessera.registry import ( EMBEDDINGS_DIR_NAME, LANDMASKS_DIR_NAME, + tile_from_world, tile_to_landmask_filename, tile_to_embedding_paths, ) @@ -118,6 +119,26 @@ def format_bbox(bbox_coords) -> str: return f"[{min_lon_str}, {min_lat_str}] - [{max_lon_str}, {max_lat_str}]" +def point_to_tile_bbox(lon: float, lat: float) -> tuple: + """Convert a point to the bounding box of its containing tile. + + Tiles are 0.1x0.1 degree squares centered at 0.05-degree offsets. + Returns a point bbox at the tile center, which the registry query + will expand by 0.05 degrees to match exactly one tile. + + Args: + lon: Longitude in decimal degrees + lat: Latitude in decimal degrees + + Returns: + Tuple of (min_lon, min_lat, max_lon, max_lat) for the containing tile + """ + tile_lon, tile_lat = tile_from_world(lon, lat) + # Return point bbox at tile center - registry expands by 0.05 degrees + # which will match exactly this one tile + return (tile_lon, tile_lat, tile_lon, tile_lat) + + app = typer.Typer( name="geotessera", help=f"GeoTessera v{__version__}: Download satellite embedding tiles as GeoTIFFs", @@ -533,6 +554,17 @@ def coverage( help="Country name to focus coverage map on (e.g., 'United Kingdom', 'UK', 'GB')", ), ] = None, + bbox: Annotated[ + Optional[str], + typer.Option( + "--bbox", + help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'", + ), + ] = None, + tile: Annotated[ + Optional[str], + typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"), + ] = None, tile_color: Annotated[ str, typer.Option( @@ -611,13 +643,60 @@ def coverage( country_geojson_file = None region_file_temp = None # Track if we created a temporary file - if region_file and country: + # Check mutual exclusivity of region options + region_sources = sum(1 for x in [bbox, tile, region_file, country] if x) + if region_sources > 1: rprint( - "[red]Error: Cannot specify both --region-file and --country. Choose one.[/red]" + "[red]Error: Cannot specify multiple region options. " + "Choose one of: --bbox, --tile, --region-file, --country[/red]" ) raise typer.Exit(1) - if region_file: + if tile: + try: + tile_coords = tuple(map(float, tile.split(","))) + if len(tile_coords) != 2: + rprint("[red]Error: --tile must be 'lon,lat'[/red]") + raise typer.Exit(1) + lon, lat = tile_coords + tile_center = tile_from_world(lon, lat) + region_bbox = point_to_tile_bbox(lon, lat) + rprint( + f"[green]Point ({lon}, {lat}) -> tile " + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" + ) + rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}") + except ValueError as e: + rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]") + raise typer.Exit(1) + elif bbox: + try: + bbox_coords = tuple(map(float, bbox.split(","))) + if len(bbox_coords) == 2: + # Two coordinates = single tile + lon, lat = bbox_coords + tile_center = tile_from_world(lon, lat) + region_bbox = point_to_tile_bbox(lon, lat) + rprint( + f"[green]Point ({lon}, {lat}) -> tile " + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" + ) + elif len(bbox_coords) == 4: + region_bbox = bbox_coords + else: + rprint( + "[red]Error: bbox must be 'lon,lat' (single tile) " + "or 'min_lon,min_lat,max_lon,max_lat'[/red]" + ) + raise typer.Exit(1) + rprint(f"[green]Region bounding box:[/green] {format_bbox(region_bbox)}") + except ValueError: + rprint( + "[red]Error: Invalid bbox format. Use: 'lon,lat' or " + "'min_lon,min_lat,max_lon,max_lat'[/red]" + ) + raise typer.Exit(1) + elif region_file: try: from .visualization import calculate_bbox_from_file @@ -892,7 +971,14 @@ def download( ] = None, bbox: Annotated[ Optional[str], - typer.Option("--bbox", help="Bounding box: 'min_lon,min_lat,max_lon,max_lat'"), + typer.Option( + "--bbox", + help="Bounding box: 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat'", + ), + ] = None, + tile: Annotated[ + Optional[str], + typer.Option("--tile", help="Single tile by any point within it: 'lon,lat'"), ] = None, region_file: Annotated[ Optional[str], @@ -1006,19 +1092,58 @@ def download( verify_hashes=not skip_hash, ) - # Parse bounding box - if bbox: + # Check mutual exclusivity of region options + region_sources = sum(1 for x in [bbox, tile, region_file, country] if x) + if region_sources > 1: + rprint( + "[red]Error: Cannot specify multiple region options. " + "Choose one of: --bbox, --tile, --region-file, --country[/red]" + ) + raise typer.Exit(1) + + # Parse bounding box or tile + if tile: + try: + tile_coords = tuple(map(float, tile.split(","))) + if len(tile_coords) != 2: + rprint("[red]Error: --tile must be 'lon,lat'[/red]") + raise typer.Exit(1) + lon, lat = tile_coords + tile_center = tile_from_world(lon, lat) + bbox_coords = point_to_tile_bbox(lon, lat) + rprint( + f"[green]Point ({lon}, {lat}) -> tile " + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" + ) + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") + except ValueError as e: + rprint(f"[red]Error: Invalid --tile format. Use 'lon,lat': {e}[/red]") + raise typer.Exit(1) + elif bbox: try: bbox_coords = tuple(map(float, bbox.split(","))) - if len(bbox_coords) != 4: + if len(bbox_coords) == 2: + # Two coordinates = single tile + lon, lat = bbox_coords + tile_center = tile_from_world(lon, lat) + bbox_coords = point_to_tile_bbox(lon, lat) rprint( - "[red]Error: bbox must be 'min_lon,min_lat,max_lon,max_lat'[/red]" + f"[green]Point ({lon}, {lat}) -> tile " + f"grid_{tile_center[0]:.2f}_{tile_center[1]:.2f}[/green]" + ) + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") + elif len(bbox_coords) == 4: + rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") + else: + rprint( + "[red]Error: bbox must be 'lon,lat' (single tile) " + "or 'min_lon,min_lat,max_lon,max_lat'[/red]" ) raise typer.Exit(1) - rprint(f"[green]Using bounding box:[/green] {format_bbox(bbox_coords)}") except ValueError: rprint( - "[red]Error: Invalid bbox format. Use: 'min_lon,min_lat,max_lon,max_lat'[/red]" + "[red]Error: Invalid bbox format. Use: 'lon,lat' or " + "'min_lon,min_lat,max_lon,max_lat'[/red]" ) raise typer.Exit(1) elif region_file: @@ -1098,10 +1223,14 @@ def country_progress_callback(current: int, total: int, status: str = None): rprint(f"[green]Using country '{country}':[/green] {format_bbox(bbox_coords)}") else: rprint( - "[red]Error: Must specify either --bbox, --region-file, or --country[/red]" + "[red]Error: Must specify either --bbox, --tile, --region-file, or --country[/red]" ) rprint("Examples:") - rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area") + rprint(" --tile '0.17,52.23' # Single tile containing point") + rprint( + " --bbox '0.17,52.23' # Same as above (2 coords = single tile)" + ) + rprint(" --bbox '-0.2,51.4,0.1,51.6' # London area (4 coords = region)") rprint(" --region-file london.geojson # From GeoJSON file") rprint(" --country 'United Kingdom' # Country by name") raise typer.Exit(1) diff --git a/geotessera/registry_cli.py b/geotessera/registry_cli.py index e9dce75..2e92898 100644 --- a/geotessera/registry_cli.py +++ b/geotessera/registry_cli.py @@ -761,6 +761,7 @@ def progress_callback(current, total, status): # Group warnings by reason for better readability from collections import defaultdict + warnings_by_reason = defaultdict(list) for reason, path in warnings: warnings_by_reason[reason].append(path) @@ -780,7 +781,9 @@ def progress_callback(current, total, status): with open(missing_embeddings_file, "w") as f: for path in sorted(missing_embeddings): f.write(f"{path}\n") - console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]") + console.print( + f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]" + ) # Write missing scales missing_scales = warnings_by_reason.get("missing scales", []) @@ -789,7 +792,9 @@ def progress_callback(current, total, status): with open(missing_scales_file, "w") as f: for path in sorted(missing_scales): f.write(f"{path}\n") - console.print(f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]") + console.print( + f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]" + ) return True @@ -901,6 +906,7 @@ def progress_callback(current, total, status): # Group warnings by reason for better readability from collections import defaultdict + warnings_by_reason = defaultdict(list) for reason, path in warnings: warnings_by_reason[reason].append(path) @@ -920,7 +926,9 @@ def progress_callback(current, total, status): with open(missing_embeddings_file, "w") as f: for path in sorted(missing_embeddings): f.write(f"{path}\n") - console.print(f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]") + console.print( + f"\n[dim]Written {len(missing_embeddings)} paths to {missing_embeddings_file}[/dim]" + ) # Write missing scales missing_scales = warnings_by_reason.get("missing scales", []) @@ -929,7 +937,9 @@ def progress_callback(current, total, status): with open(missing_scales_file, "w") as f: for path in sorted(missing_scales): f.write(f"{path}\n") - console.print(f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]") + console.print( + f"[dim]Written {len(missing_scales)} paths to {missing_scales_file}[/dim]" + ) return 0 diff --git a/tests/cli.t b/tests/cli.t index 2e8c5ad..10f6998 100644 --- a/tests/cli.t +++ b/tests/cli.t @@ -35,7 +35,7 @@ Test the info command without arguments to see library information. We just verify key information is present, ignoring formatting: $ geotessera info --dataset-version v1 2>&1 | grep -E 'Available years' - Available years: 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025 + Available years: 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025 Test: Download Dry Run for UK Tile ----------------------------------- diff --git a/tests/tile.t b/tests/tile.t new file mode 100644 index 0000000..8fc2b35 --- /dev/null +++ b/tests/tile.t @@ -0,0 +1,128 @@ +GeoTessera Single Tile Tests +============================= + +These are tests for the single tile download functionality using --tile and 2-coord --bbox. + +Setup +----- + +Set environment variable to disable fancy terminal output (ANSI codes, boxes, colors): + + $ export TERM=dumb + +Create a temporary directory for test outputs and cache: + + $ export TESTDIR="$CRAMTMP/test_outputs" + $ mkdir -p "$TESTDIR" + +Override XDG cache directory to use temporary location (for test isolation): + + $ export XDG_CACHE_HOME="$CRAMTMP/cache" + $ mkdir -p "$XDG_CACHE_HOME" + +Test: Single Tile with --tile Option (Dry Run) +---------------------------------------------- + +Test downloading a single tile using the --tile option with a point coordinate. +The point (0.17, 52.23) should resolve to tile grid_0.15_52.25: + + $ geotessera download \ + > --tile "0.17,52.23" \ + > --year 2024 \ + > --format tiff \ + > --dry-run \ + > --dataset-version v1 2>&1 | grep -E '(Point|tile grid_|Found|Files to download|Tiles in region)' + Point (0.17, 52.23) -> tile grid_0.15_52.25 + Found 1 tiles for region in year 2024 + Files to download: 1 + Tiles in region: 1 + +Test: Single Tile with 2-coord --bbox (Dry Run) +----------------------------------------------- + +Test downloading a single tile using --bbox with only 2 coordinates. +This should behave identically to --tile: + + $ geotessera download \ + > --bbox "0.17,52.23" \ + > --year 2024 \ + > --format tiff \ + > --dry-run \ + > --dataset-version v1 2>&1 | grep -E '(Point|tile grid_|Found|Files to download|Tiles in region)' + Point (0.17, 52.23) -> tile grid_0.15_52.25 + Found 1 tiles for region in year 2024 + Files to download: 1 + Tiles in region: 1 + +Test: Mutual Exclusivity of Region Options +------------------------------------------ + +Test that specifying multiple region options produces an error: + + $ geotessera download \ + > --tile "0.17,52.23" \ + > --bbox "-0.1,51.3,0.1,51.5" \ + > --year 2024 \ + > --dry-run \ + > --dataset-version v1 2>&1 | grep -E 'Cannot specify multiple region' + Error: Cannot specify multiple region options. Choose one of: --bbox, --tile, + +Test: Invalid --tile Format (Wrong Number of Coords) +----------------------------------------------------- + +Test that --tile with wrong number of coordinates produces an error: + + $ geotessera download \ + > --tile "0.17,52.23,0.20" \ + > --year 2024 \ + > --dry-run \ + > --dataset-version v1 2>&1 | grep -E "Error.*--tile must be" + Error: --tile must be 'lon,lat' + +Test: Invalid --bbox Format (Wrong Number of Coords) +----------------------------------------------------- + +Test that --bbox with 3 coordinates produces an error: + + $ geotessera download \ + > --bbox "0.17,52.23,0.20" \ + > --year 2024 \ + > --dry-run \ + > --dataset-version v1 2>&1 | grep -E "Error.*bbox must be" + Error: bbox must be 'lon,lat' (single tile) or 'min_lon,min_lat,max_lon,max_lat' + +Test: Download Single Tile (Actual Download) +--------------------------------------------- + +Download a single tile using --tile option: + + $ geotessera download \ + > --tile "0.17,52.23" \ + > --year 2024 \ + > --format tiff \ + > --output "$TESTDIR/single_tile_tiff" \ + > --dataset-version v1 2>&1 | grep -E '(Point|SUCCESS)' | sed 's/ *$//' + Point (0.17, 52.23) -> tile grid_0.15_52.25 + SUCCESS: Exported 1 GeoTIFF files + +Verify that exactly one TIFF file was created: + + $ find "$TESTDIR/single_tile_tiff/global_0.1_degree_representation/2024" -name "*.tif*" | wc -l | tr -d ' ' + 1 + +Verify the tile is named correctly (grid_0.15_52.25): + + $ find "$TESTDIR/single_tile_tiff/global_0.1_degree_representation/2024" -type d -name "grid_*" | xargs -I {} basename {} + grid_0.15_52.25 + +Test: Coverage Command with --tile Option +----------------------------------------- + +Test that coverage command also accepts --tile option and parses the tile correctly: + + $ geotessera coverage \ + > --tile "0.17,52.23" \ + > --output "$TESTDIR/single_tile_coverage.png" \ + > --dataset-version v1 2>&1 | head -2 + Point (0.17, 52.23) -> tile grid_0.15_52.25 + Region bounding box: [0.150000\xc2\xb0E, 52.250000\xc2\xb0N] - [0.150000\xc2\xb0E, 52.250000\xc2\xb0N] (esc)