diff --git a/docs/plans/2026-02-25-pyramid-design.md b/docs/plans/2026-02-25-pyramid-design.md new file mode 100644 index 0000000..574de23 --- /dev/null +++ b/docs/plans/2026-02-25-pyramid-design.md @@ -0,0 +1,99 @@ +# UTM-native Pyramids for RGB/PCA Previews + +## Problem + +Current Zarr stores have single-resolution RGB and PCA preview arrays at 10m/px. +Browser clients must fetch full-resolution chunks even when zoomed out, wasting +bandwidth and making overview rendering slow for large UTM zones (~60k × 600k px). + +## Design + +Build multi-resolution pyramids of the existing `rgb` and `pca_rgb` RGBA arrays +using iterative 2× mean coarsening. Pyramids stay in UTM projection (no +reprojection to Web Mercator). The client selects the appropriate level based on +zoom and reprojects per-chunk as it does today. + +### Store Layout + +``` +utm30_2025.zarr/ +├── embeddings/ # unchanged +├── scales/ # unchanged +├── rgb/ # full-res RGBA uint8 (level 0) +├── pca_rgb/ # full-res RGBA uint8 (level 0) +├── rgb_pyramid/ +│ ├── 1/ # 2× coarsened (20m) +│ ├── 2/ # 4× coarsened (40m) +│ ├── 3/ # 8× coarsened (80m) +│ ├── 4/ # 16× coarsened (160m) +│ ├── 5/ # 32× coarsened (320m) +│ ├── 6/ # 64× coarsened (640m) +│ └── 7/ # 128× coarsened (1280m) +├── pca_rgb_pyramid/ +│ ├── 1/ … 7/ +├── easting/ +├── northing/ +└── band/ +``` + +Level 0 is the existing `rgb`/`pca_rgb` array — no duplication. Levels 1–7 are +stored under `{name}_pyramid/{level}/` as RGBA uint8 arrays with the same +1024×1024×4 chunking. + +### Coarsening Method + +Each level halves both spatial dimensions by averaging 2×2 blocks using +`xarray.DataArray.coarsen(northing=2, easting=2).mean()`. Alpha channel is +averaged too, correctly handling partial-data boundaries where some pixels in a +block are transparent. + +8 fixed levels total (level 0 = full-res, levels 1–7 stored as pyramid groups). + +### Per-Level Metadata + +Each pyramid group stores attrs: + +```python +{ + "level": int, # pyramid level (1–7) + "pixel_size_m": float, # 10 * 2^level + "transform": [6 floats], # adjusted affine for this resolution + "shape": [height, width], # dimensions at this level +} +``` + +### Root Store Metadata + +Added to existing store attrs: + +```python +{ + "has_rgb_pyramid": True, + "rgb_pyramid_levels": 8, # total including level 0 + "has_pca_pyramid": True, + "pca_pyramid_levels": 8, +} +``` + +### CLI Integration + +Two new flags on `geotessera-registry zarr-build`: + +- `--pyramid` — build pyramids after writing previews (requires rgb/pca to exist) +- `--pyramid-only` — scan existing stores, build/rebuild pyramids from full-res + preview arrays without regenerating the full-res data + +### Dependencies + +No new dependencies. Uses `xarray.coarsen` (xarray is already a dependency). +ndpyramid's value is mainly for reprojection which we don't need since pyramids +stay in UTM. + +### Implementation Notes + +- Read full-res array as xarray DataArray, iteratively coarsen and write each level +- Chunk size stays 1024×1024×4 at all levels (coarser levels have fewer chunks) +- Uncompressed storage (consistent with existing preview arrays) +- Progress reporting via Rich (one progress bar per store per preview type) +- `--pyramid-only` scans for existing `.zarr` stores and checks `has_rgb_preview` + / `has_pca_preview` attrs to decide what to build diff --git a/docs/plans/2026-02-25-pyramid-implementation.md b/docs/plans/2026-02-25-pyramid-implementation.md new file mode 100644 index 0000000..1eb0a31 --- /dev/null +++ b/docs/plans/2026-02-25-pyramid-implementation.md @@ -0,0 +1,452 @@ +# Pyramid Preview Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add multi-resolution pyramid generation for RGB and PCA preview arrays in UTM-native Zarr stores, with `--pyramid` and `--pyramid-only` CLI flags. + +**Architecture:** A new `build_pyramids_for_store()` function in `zarr_zone.py` reads the full-res `rgb` or `pca_rgb` array from an existing store, iteratively coarsens it 7 times (2× mean each), and writes each level as a sub-group. The CLI wires `--pyramid` and `--pyramid-only` flags following the same pattern as `--rgb-only`/`--pca-only`. + +**Tech Stack:** xarray (coarsen), zarr (group creation), numpy (array ops), Rich (progress) + +--- + +### Task 1: Core pyramid builder function + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add after `add_pca_to_existing_store`, ~line 1279) + +**Step 1: Write the function `build_preview_pyramid`** + +Add this function to `zarr_zone.py` after the `add_pca_to_existing_store` function (around line 1279): + +```python +PYRAMID_LEVELS = 8 # total levels: 0 (full-res) + 1..7 (coarsened) + + +def build_preview_pyramid( + store: "zarr.Group", + preview_name: str, + console: Optional["rich.console.Console"] = None, +) -> int: + """Build a multi-resolution pyramid from an existing preview array. + + Reads store[preview_name] (full-res RGBA uint8) and creates + store[f"{preview_name}_pyramid/{level}"] for levels 1..7, + each halving both spatial dimensions via 2x2 mean coarsening. + + Args: + store: Zarr group opened in r+ mode. + preview_name: "rgb" or "pca_rgb". + console: Optional Rich Console for progress. + + Returns: + Number of pyramid levels written (7 on success). + """ + import xarray as xr + + try: + src_arr = store[preview_name] + except KeyError: + if console is not None: + console.print(f" [yellow]No {preview_name} array found, skipping pyramid[/yellow]") + return 0 + + h, w = src_arr.shape[:2] + if h == 0 or w == 0: + return 0 + + # Read full-res into xarray for coarsening + data = np.asarray(src_arr[:]) + da = xr.DataArray( + data, + dims=["northing", "easting", "rgba"], + ) + + # Get transform from store attrs for per-level metadata + transform = store.attrs.get("transform", [10.0, 0.0, 0.0, 0.0, -10.0, 0.0]) + base_pixel_size = transform[0] + origin_easting = transform[2] + origin_northing = transform[5] + + group_name = f"{preview_name}_pyramid" + + # Delete existing pyramid group if present + if group_name in store: + import shutil + group_path = Path(store.store.path) / group_name + if group_path.exists(): + shutil.rmtree(group_path) + + pyramid_group = store.create_group(group_name) + + levels_written = 0 + current = da + + if console is not None: + from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn + progress_ctx = Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), TaskProgressColumn(), + console=console, + ) + else: + from contextlib import nullcontext + progress_ctx = nullcontext() + + with progress_ctx as progress: + if progress is not None: + task = progress.add_task( + f"Building {preview_name} pyramid", total=PYRAMID_LEVELS - 1, + ) + + for level in range(1, PYRAMID_LEVELS): + # Coarsen 2x in both spatial dims; boundary="trim" drops remainder + nh, nw = current.sizes["northing"], current.sizes["easting"] + if nh < 2 or nw < 2: + break # too small to coarsen further + + coarsened = ( + current + .coarsen(northing=2, easting=2, boundary="trim") + .mean() + .astype(np.uint8) + ) + + level_h, level_w = coarsened.sizes["northing"], coarsened.sizes["easting"] + pixel_size = base_pixel_size * (2 ** level) + + # Create array in pyramid group + level_arr = pyramid_group.create_array( + str(level), + shape=(level_h, level_w, 4), + chunks=(min(1024, level_h), min(1024, level_w), 4), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=None, + ) + level_arr[:] = coarsened.values + + # Per-level metadata + level_arr.attrs.update({ + "level": level, + "pixel_size_m": pixel_size, + "transform": [ + pixel_size, 0.0, origin_easting, + 0.0, -pixel_size, origin_northing, + ], + "shape": [level_h, level_w], + }) + + current = coarsened + levels_written += 1 + + if progress is not None: + progress.advance(task) + + return levels_written +``` + +**Step 2: Verify module still imports cleanly** + +Run: `uv run python -c "from geotessera.zarr_zone import build_preview_pyramid, PYRAMID_LEVELS; print('OK')"` +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "zarr: add build_preview_pyramid for multi-resolution previews" +``` + +--- + +### Task 2: Store-level pyramid builder + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add after `build_preview_pyramid`) + +**Step 1: Write `add_pyramids_to_existing_store`** + +Add this function immediately after `build_preview_pyramid`: + +```python +def add_pyramids_to_existing_store( + store_path: Path, + console: Optional["rich.console.Console"] = None, +) -> None: + """Build pyramids for all preview arrays in an existing Zarr store.""" + import zarr + + store = zarr.open_group(str(store_path), mode="r+") + attrs = dict(store.attrs) + + for preview_name, attr_key in [("rgb", "has_rgb_preview"), ("pca_rgb", "has_pca_preview")]: + if not attrs.get(attr_key, False): + continue + + pyramid_attr = f"has_{preview_name}_pyramid" + if attrs.get(pyramid_attr, False): + if console is not None: + console.print(f" [dim]{preview_name} pyramid already exists, rebuilding[/dim]") + + if console is not None: + console.print(f" Building {preview_name} pyramid...") + + levels = build_preview_pyramid(store, preview_name, console=console) + + if levels > 0: + store.attrs.update({ + pyramid_attr: True, + f"{preview_name}_pyramid_levels": levels + 1, # includes level 0 + }) + if console is not None: + console.print(f" [green]{preview_name} pyramid: {levels} levels written[/green]") +``` + +**Step 2: Verify import** + +Run: `uv run python -c "from geotessera.zarr_zone import add_pyramids_to_existing_store; print('OK')"` +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "zarr: add add_pyramids_to_existing_store wrapper" +``` + +--- + +### Task 3: CLI flags `--pyramid` and `--pyramid-only` + +**Files:** +- Modify: `geotessera/registry_cli.py` (~line 3395 in zarr-build argparse, ~line 2526 in zarr_build_command) + +**Step 1: Add argparse flags** + +In `main()`, after the `--pca-only` argument (around line 3408), add: + +```python + zarr_build_parser.add_argument( + "--pyramid", + action="store_true", + help="Generate multi-resolution pyramids for preview arrays during build", + ) + zarr_build_parser.add_argument( + "--pyramid-only", + action="store_true", + help="Add pyramids to existing stores without rebuilding " + "(scans existing .zarr stores in output dir)", + ) +``` + +**Step 2: Add `--pyramid-only` handler in `zarr_build_command`** + +In `zarr_build_command()`, after the `--pca-only` handler block (around line 2635), add a new block following the same pattern. Add `add_pyramids_to_existing_store` to the import at the top of the function (line 2528): + +Update the import line: +```python + from .zarr_zone import build_zone_stores, add_rgb_to_existing_store, add_pca_to_existing_store, add_pyramids_to_existing_store +``` + +Then add the handler block: +```python + # Handle --pyramid-only mode: add pyramids to existing stores + if args.pyramid_only: + zarr_dir = Path(output_dir) + if not zarr_dir.is_dir(): + console.print(f"[red]Error: directory not found: {zarr_dir}[/red]") + return 1 + + zone_filter = None + if args.zones: + try: + zone_filter = {int(z.strip()) for z in args.zones.split(",")} + except ValueError: + console.print("[red]Error: --zones must be comma-separated integers[/red]") + return 1 + + def _store_matches_pyr(p): + if zone_filter is None: + return True + try: + zone_num = int(p.name.split("_")[0].replace("utm", "")) + return zone_num in zone_filter + except (ValueError, IndexError): + return True + + zarr_stores = sorted( + p for p in zarr_dir.iterdir() + if p.is_dir() and p.name.endswith(".zarr") and _store_matches_pyr(p) + ) + + if not zarr_stores: + console.print(f"[yellow]No .zarr stores found in {zarr_dir}[/yellow]") + return 1 + + console.print( + f"[bold]Adding pyramids to existing stores[/bold]\n" + f" Directory: {zarr_dir}\n" + f" Stores: {len(zarr_stores)}" + ) + if zone_filter: + console.print(f" Zones: {', '.join(str(z) for z in sorted(zone_filter))}") + + for store_path in zarr_stores: + console.print(f"\n [cyan]{store_path.name}[/cyan]") + add_pyramids_to_existing_store(store_path, console=console) + + console.print(f"\n[bold green]Pyramids added to {len(zarr_stores)} store(s)[/bold green]") + return 0 +``` + +**Step 3: Wire `--pyramid` into `build_zone_stores` call** + +In `zarr_build_command()`, find the call to `build_zone_stores()` (around line 2680). After the RGB/PCA preview generation completes in `build_zone_stores` (around line 769 in zarr_zone.py), add pyramid building. + +Add `pyramid: bool = False` parameter to `build_zone_stores` signature and wire it. At the end of the per-zone loop (after `pca` block, before `created_stores.append`), add: + +```python + if pyramid: + for preview_name in ["rgb", "pca_rgb"]: + if preview_name in store: + if console is not None: + console.print(f" Building {preview_name} pyramid...") + levels = build_preview_pyramid(store, preview_name, console=console) + if levels > 0: + store.attrs.update({ + f"has_{preview_name}_pyramid": True, + f"{preview_name}_pyramid_levels": levels + 1, + }) + if console is not None: + console.print(f" [green]{preview_name} pyramid: {levels} levels[/green]") +``` + +Then in `zarr_build_command()`, pass `pyramid=args.pyramid` to `build_zone_stores()`. + +**Step 4: Verify CLI help** + +Run: `uv run geotessera-registry zarr-build --help | grep -A1 pyramid` +Expected: Shows both `--pyramid` and `--pyramid-only` with descriptions. + +**Step 5: Commit** + +```bash +git add geotessera/registry_cli.py geotessera/zarr_zone.py +git commit -m "zarr-build: add --pyramid and --pyramid-only CLI flags" +``` + +--- + +### Task 4: Integration test + +**Files:** +- Modify: `tests/zarr.t` (append test at end) + +**Step 1: Add cram test for pyramid building** + +Append to `tests/zarr.t`: + +``` +Test: Pyramid Building on Preview Arrays +----------------------------------------- + +Build a small zone store with RGB, then add pyramids. +Use the Cambridge tiles downloaded earlier: + + $ geotessera-registry zarr-build \ + > "$TESTDIR/cb_tiles_zarr" \ + > --output-dir "$TESTDIR/zarr_pyramid_test" \ + > --year 2024 \ + > --rgb 2>&1 | grep -E '(RGB preview|Zone)' | head -3 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + +Add pyramids to the store: + + $ geotessera-registry zarr-build \ + > "$TESTDIR/cb_tiles_zarr" \ + > --output-dir "$TESTDIR/zarr_pyramid_test" \ + > --pyramid-only 2>&1 | grep -E '(pyramid|Pyramids)' | head -3 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + +Verify pyramid structure exists in the zarr store: + + $ ZARR_STORE=$(find "$TESTDIR/zarr_pyramid_test" -name "*.zarr" -type d | head -1) + $ uv run python -c " + > import zarr + > store = zarr.open_group('$ZARR_STORE', mode='r') + > attrs = dict(store.attrs) + > print(f'has_rgb_pyramid: {attrs.get(\"has_rgb_pyramid\", False)}') + > pyramid = store['rgb_pyramid'] + > levels = sorted(k for k in pyramid.keys()) + > print(f'levels: {levels}') + > level1 = pyramid['1'] + > full_h, full_w = store['rgb'].shape[:2] + > l1_h, l1_w = level1.shape[:2] + > print(f'full: {full_h}x{full_w}, level1: {l1_h}x{l1_w}') + > print(f'halved: {l1_h == full_h // 2 and l1_w == full_w // 2}') + > " + has_rgb_pyramid: True + levels: * (glob) + full: *x*, level1: *x* (glob) + halved: True +``` + +**Step 2: Run the test** + +Run: `cd tests && uv run cram zarr.t` +Expected: All tests pass (existing + new). + +**Step 3: Commit** + +```bash +git add tests/zarr.t +git commit -m "tests: add pyramid building integration test" +``` + +--- + +### Task 5: Update module docstring + +**Files:** +- Modify: `geotessera/zarr_zone.py:1-15` + +**Step 1: Update the module docstring** + +Update the store layout in the module docstring at the top of `zarr_zone.py` to reflect pyramids: + +```python +"""Zone-wide Zarr format for consolidated Tessera embeddings. + +This module provides tools for building and reading Zarr v3 stores that +consolidate all tiles within a UTM zone into a single store per year. +This enables efficient spatial subsetting and cloud-native access. + +Store layout (uncompressed): + utm{zone:02d}_{year}.zarr/ + embeddings # int8 (northing, easting, band) chunks=(1024, 1024, 128) + scales # float32 (northing, easting) chunks=(1024, 1024) + rgb # uint8 (northing, easting, rgba) chunks=(1024, 1024, 4) [optional] + pca_rgb # uint8 (northing, easting, rgba) chunks=(1024, 1024, 4) [optional] + rgb_pyramid/ # multi-resolution pyramid of rgb [optional] + 1/ .. 7/ # uint8, each level 2x coarsened + pca_rgb_pyramid/ # multi-resolution pyramid of pca_rgb [optional] + 1/ .. 7/ # uint8, each level 2x coarsened + +NaN in scales indicates no-data (water or no coverage). +Embeddings are high-entropy quantised values; compression gives negligible +benefit so we store uncompressed. +""" +``` + +**Step 2: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "docs: update zarr_zone module docstring with pyramid layout" +``` diff --git a/docs/plans/2026-02-25-stac-index-design.md b/docs/plans/2026-02-25-stac-index-design.md new file mode 100644 index 0000000..84ef340 --- /dev/null +++ b/docs/plans/2026-02-25-stac-index-design.md @@ -0,0 +1,100 @@ +# STAC Index Command Design + +**Date:** 2026-02-25 +**Status:** Approved + +## Overview + +A new `geotessera-registry stac-index` CLI command that scans a directory of Zarr stores and generates a spec-compliant static STAC catalog. Clients (TZE viewer, QGIS, pystac, stac-browser) can use it to discover available years and UTM zones, then open individual stores. + +## CLI Interface + +``` +geotessera-registry stac-index [--output-dir DIR] +``` + +- `` — Directory containing `utm{ZZ}_{YYYY}.zarr` stores +- `--output-dir` — Where to write STAC JSON files (defaults to ``) + +## Output Structure + +``` +/ +├── catalog.json # Root catalog +├── geotessera-2025/ +│ ├── collection.json # One collection per year +│ ├── utm29_2025/ +│ │ └── utm29_2025.json # One item per store +│ └── utm31_2025/ +│ └── utm31_2025.json +└── ... +``` + +The Zarr stores themselves are not modified or moved. + +## STAC Hierarchy + +### Root Catalog + +- **id:** `geotessera` +- **title:** `Geotessera Embedding Stores` +- **description:** Generated from store metadata +- **Links:** One child link per collection (year) + +### Collection (one per year) + +- **id:** `geotessera-{year}` +- **title:** `Geotessera {year}` +- **Spatial extent:** Union bounding box of all stores in that year (WGS84) +- **Temporal extent:** `{year}-01-01T00:00:00Z` to `{year}-12-31T23:59:59Z` +- **Links:** Parent (catalog), child links to items + +### Item (one per Zarr store) + +- **id:** `utm{ZZ}_{YYYY}` (matches store name without `.zarr`) +- **Geometry:** WGS84 polygon of the store's spatial extent (computed from transform + shape, reprojected from UTM) +- **Datetime:** `{year}-01-01T00:00:00Z` +- **Properties:** + - `utm_zone` — UTM zone number + - `crs_epsg` — EPSG code + - `pixel_size_m` — Pixel size in meters + - `grid_width` — Pixel width of the store + - `grid_height` — Pixel height of the store + - `n_bands` — Number of embedding bands (128) + - `has_rgb_preview` — Boolean + - `has_pca_preview` — Boolean + - `geotessera_version` — Version string +- **Assets:** + - `zarr` — Relative href to the `.zarr` directory, media type `application/x-zarr-v3`, role `data` + +## Spatial Extent Computation + +For each store, compute the WGS84 bounding box from Zarr metadata: + +1. Read `transform` (affine) and `shape` from store attrs +2. Compute four UTM corners: origin, origin + width*px, origin - height*px, etc. +3. Reproject corners from EPSG to WGS84 using pyproj +4. Take the bounding box of the reprojected corners + +This reuses the same projection logic already in `zarr_zone.py`. + +## Dependencies + +- **pystac** — Catalog/Collection/Item construction, link management, serialization +- **pyproj** — UTM to WGS84 reprojection (already a dependency) +- **zarr** — Reading store metadata (already a dependency) + +`pystac` is added as a new dependency in `pyproject.toml`. + +## Implementation Location + +- New function `stac_index_command(args)` in `registry_cli.py` +- Helper `_zarr_store_to_stac_item()` for per-store metadata extraction +- Helper `_store_bbox_wgs84()` for spatial extent computation +- New subparser added alongside existing commands + +## Error Handling + +- Stores that can't be opened or lack required metadata are skipped with a warning +- If no valid stores are found, exit with an error message +- Overwrite existing STAC JSON files (idempotent regeneration) diff --git a/docs/plans/2026-02-26-mercator-pyramids-design.md b/docs/plans/2026-02-26-mercator-pyramids-design.md new file mode 100644 index 0000000..c7cf4ea --- /dev/null +++ b/docs/plans/2026-02-26-mercator-pyramids-design.md @@ -0,0 +1,165 @@ +# Web Mercator Pyramids via ndpyramid + +## Problem + +The current approach stores preview pyramids in UTM projection and reprojects +to Web Mercator per-pixel in the browser tile handler. This causes: + +- Misalignment between tiles and the basemap +- Complex client code (65K coordinate transforms per 256px tile) +- Bugs in transform handling across pyramid levels +- Poor zoom transition behaviour + +## Solution + +Generate Web Mercator (EPSG:3857) pyramids server-side using ndpyramid's +`pyramid_reproject`. The tile handler becomes a trivial array slice — no +reprojection, no transform math, perfect basemap alignment by construction. + +## Store layout + +Existing UTM arrays are unchanged. Old UTM pyramid groups are replaced: + +``` +utm30_2024.zarr/ + embeddings # int8 (northing, easting, band) — UTM, unchanged + scales # f32 (northing, easting) — UTM, unchanged + rgb # uint8 (northing, easting, rgba) — UTM, unchanged + pca_rgb # uint8 (northing, easting, rgba) — UTM, unchanged + rgb_mercator/ # NEW — replaces rgb_pyramid/ + 6/ # zoom 6: coarsest (~1571 m/px at 50°N) + 7/ + ... + 12/ # zoom 12: finest stored (~24.5 m/px at 50°N) + pca_rgb_mercator/ # NEW — replaces pca_rgb_pyramid/ + 6/ .. 12/ +``` + +Each zoom level is a zarr array: +- **Shape**: `(total_y_pixels, total_x_pixels, 4)` — covers the UTM zone's + bounding box projected into EPSG:3857 +- **Chunks**: `(256, 256, 4)` — one chunk = one XYZ tile = one HTTP fetch +- **Dtype**: uint8 (RGBA) + +## Zoom level range + +Computed from the base pixel size (10 m) and dataset centre latitude: + +- **max_zoom** = 12 — 24.5 m/pixel, 2.5× coarser than base. MapLibre + overzooms for z=13+. Double-click loads full-res embeddings anyway. +- **min_zoom** = 6 — ~1571 m/pixel. Whole UTM zone fits in a few tiles. +- **Total**: 7 levels (z=6 through z=12) + +Storage estimate per zone (rgb only): ~4.6 GB for 7 levels. The finest +level (z=12) dominates. + +## Store attributes + +Remove: +- `has_rgb_pyramid`, `rgb_pyramid_levels` +- `has_pca_rgb_pyramid`, `pca_rgb_pyramid_levels` + +Add: +- `has_rgb_mercator: true` +- `has_pca_rgb_mercator: true` +- `mercator_zoom_range: [6, 12]` +- `mercator_tile_bounds: {6: {x_min, x_max, y_min, y_max}, ...}` — per-level + tile index offsets so the client knows which (x, y) maps to array index (0, 0) + +## Python changes (`zarr_zone.py`) + +### New function: `build_mercator_pyramid()` + +1. Read UTM `rgb` (or `pca_rgb`) array from store +2. Wrap in xarray DataArray with CRS (from store `crs_epsg`) and affine + transform (from store `transform`) +3. Call `ndpyramid.pyramid_reproject(ds, levels=7, projection='web-mercator', + pixels_per_tile=256, resampling='bilinear')` +4. Write each level to `rgb_mercator/{z}` with chunks=(256, 256, 4) +5. Compute and store per-level tile bounds in attrs + +### New CLI command: `add-mercator-pyramids` + +``` +geotessera add-mercator-pyramids /path/to/utm30_2024.zarr [--max-zoom 12] +``` + +- Opens existing store in `r+` mode +- Deletes old `rgb_pyramid/` and `pca_rgb_pyramid/` groups if present +- Generates Web Mercator pyramids for each preview array that exists +- Updates store attributes +- Works on stores with or without old UTM pyramids + +### Remove + +- `build_preview_pyramid()` — replaced +- `_coarsen_strip()` — no longer needed +- `PYRAMID_LEVELS` constant — replaced by zoom range computation +- `add_pyramids_to_existing_store()` — replaced by `add-mercator-pyramids` + +### New dependency + +- `ndpyramid[xesmf]` (includes rioxarray, xarray, xesmf for regridding) + +## TypeScript changes (`maplibre-zarr-tessera`) + +### `zarr-reader.ts` + +- Open `rgb_mercator/{z}` arrays instead of `rgb_pyramid/{level}` +- Read `mercator_zoom_range` and `mercator_tile_bounds` from attrs +- Store in `StoreMetadata` + +### `zarr-source.ts` — tile handler + +The `handleTileRequest` simplifies to: + +```typescript +async handleTileRequest(params, abortController) { + const {z, x, y} = parseUrl(params.url); + if (z < minZoom || z > maxZoom) return transparent(); + + const arr = store.rgbMercatorArrs.get(z); + const bounds = store.meta.mercatorTileBounds[z]; + + // Offset: array (0,0) = tile (bounds.x_min, bounds.y_min) + const ax = x - bounds.x_min; + const ay = y - bounds.y_min; + if (ax < 0 || ay < 0) return transparent(); + + const region = await fetchRegion(arr, + [[ay * 256, (ay + 1) * 256], [ax * 256, (ax + 1) * 256], null]); + return { data: encodePng(region) }; +} +``` + +No `pickPyramidLevel()`. No UTM projection. No bilinear interpolation. +No `fetchZarrChunks()`. No chunk cache. No concurrency semaphore. + +### Remove from `zarr-source.ts` + +- `pickPyramidLevel()` — zoom level maps directly to array +- `fetchZarrChunks()` / `fetchSingleChunk()` — one tile = one fetchRegion +- `zarrChunkCache` / `zarrChunkInflight` — MapLibre handles caching +- `acquireFetchSlot()` / `releaseFetchSlot()` — MapLibre limits concurrency +- `pyramidMeta()` for preview levels — not needed +- Per-pixel UTM→Mercator reprojection loop + +### Keep unchanged + +- Embedding loading (double-click → `loadFullChunk` in UTM) +- Classification overlays +- Grid/UTM boundary overlays +- Spatial query APIs (`getEmbeddingAt`, `getEmbeddingsInKernel`) +- Worker pool (used by embedding rendering) +- Event system + +## Testing + +1. Generate mercator pyramids for a test zone: `geotessera add-mercator-pyramids test.zarr` +2. Verify store structure: levels 6–12 exist, chunks are 256×256×4 +3. Build viewer: `cd tze && pnpm build` +4. Visual: tiles align with basemap at all zoom levels +5. Visual: smooth zoom transitions (MapLibre cross-fades) +6. Visual: preview mode switch (rgb ↔ pca) reloads tiles +7. Double-click: still loads full-res embeddings +8. Classification: still works on embedding tiles diff --git a/docs/plans/2026-02-26-mercator-pyramids-implementation.md b/docs/plans/2026-02-26-mercator-pyramids-implementation.md new file mode 100644 index 0000000..fa31f31 --- /dev/null +++ b/docs/plans/2026-02-26-mercator-pyramids-implementation.md @@ -0,0 +1,962 @@ +# Web Mercator Pyramids Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace UTM-native preview pyramids with ndpyramid-generated Web Mercator pyramids, and simplify the client tile handler to a trivial array slice. + +**Architecture:** Server-side `ndpyramid.pyramid_reproject()` generates EPSG:3857 pyramids from the existing UTM rgb/pca_rgb arrays. Each zoom level is stored with 256x256 chunks so one tile = one zarr chunk fetch. The client tile handler just parses z/x/y, offsets into the array, and returns the chunk — no reprojection. + +**Tech Stack:** Python (ndpyramid, rioxarray, xarray, zarr), TypeScript (zarrita, maplibre-gl) + +--- + +## Context + +**Repositories:** +- `~/src/git/ucam-eo/geotessera/` — Python zarr generation (`geotessera/zarr_zone.py`, `geotessera/registry_cli.py`) +- `~/src/git/ucam-eo/tze/packages/maplibre-zarr-tessera/src/` — TypeScript tile viewer (`zarr-source.ts`, `zarr-reader.ts`, `types.ts`) + +**Current state:** The code on the `zarr2` branch has a broken client-side UTM→Mercator reprojection in `zarr-source.ts`. Pyramids are generated in UTM space by `build_preview_pyramid()` in `zarr_zone.py` using 2x mean coarsening. The client opens them at `rgb_pyramid/{level}`. + +**Design doc:** `docs/plans/2026-02-26-mercator-pyramids-design.md` + +**Key numbers:** +- Base pixel size: 10m +- Useful zoom range: z=6 (coarsest) to z=12 (finest stored), 7 levels +- Zarr chunks: 256×256×4 (uint8 RGBA) = 256 KB per tile +- Storage: ~4.6 GB per UTM zone for rgb pyramids + +--- + +## Task 1: Add ndpyramid dependency + +**Files:** +- Modify: `geotessera/pyproject.toml` + +**Step 1: Add ndpyramid to dependencies** + +In `pyproject.toml`, add `"ndpyramid"` to the `dependencies` list. ndpyramid +pulls in xesmf for regridding; xarray and rioxarray are already dependencies. + +```toml +dependencies = [ + ...existing deps... + "ndpyramid", +] +``` + +**Step 2: Verify installation** + +Run: +```bash +cd ~/src/git/ucam-eo/geotessera +uv sync +uv run python -c "import ndpyramid; print('ndpyramid OK')" +``` +Expected: `ndpyramid OK` + +**Step 3: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "deps: add ndpyramid for Web Mercator pyramid generation" +``` + +--- + +## Task 2: Implement `build_mercator_pyramid()` in zarr_zone.py + +**Files:** +- Modify: `geotessera/zarr_zone.py:1303-1502` (replace pyramid section) + +This is the core Python change. Replace `build_preview_pyramid()`, `_coarsen_strip()`, and `PYRAMID_LEVELS` with a new `build_mercator_pyramid()` function. + +**Step 1: Write the new function** + +Replace lines 1303–1502 of `zarr_zone.py` (the entire "Preview pyramids" section) with: + +```python +# ============================================================================= +# Web Mercator preview pyramids (via ndpyramid) +# ============================================================================= + + +def _compute_mercator_zoom_range( + pixel_size_m: float, + center_lat: float, + max_zoom: int = 12, +) -> Tuple[int, int]: + """Compute the useful Web Mercator zoom range for a dataset. + + Args: + pixel_size_m: Base pixel size in metres (e.g. 10.0). + center_lat: Centre latitude in degrees for cos(lat) correction. + max_zoom: Finest zoom level to generate (default 12 ≈ 24.5 m/px). + + Returns: + (min_zoom, max_zoom) inclusive. + """ + cos_lat = math.cos(math.radians(center_lat)) + # Finest zoom where one tile pixel ≈ pixel_size_m + finest = math.ceil(math.log2(40_075_016.686 * cos_lat / (256 * pixel_size_m))) + # Cap at max_zoom; MapLibre will overzoom beyond this + finest = min(finest, max_zoom) + # Coarsest: 7 levels below finest, but no less than 0 + coarsest = max(0, finest - 6) + return coarsest, finest + + +def _utm_array_to_xarray( + store: "zarr.Group", + array_name: str, +) -> "xarray.DataArray": + """Wrap a UTM zarr preview array as a georeferenced xarray DataArray. + + Reads the array data and store attributes (transform, CRS) to create + an xarray DataArray with proper spatial coordinates and CRS metadata + that ndpyramid/rioxarray can reproject. + + Args: + store: Zarr group opened in read mode. + array_name: Name of the array (e.g. "rgb" or "pca_rgb"). + + Returns: + xarray DataArray with dims (y, x, band), CRS set, and spatial coords. + """ + import xarray as xr + import rioxarray # noqa: F401 — needed for .rio accessor + from rasterio.transform import Affine + + arr = store[array_name] + attrs = dict(store.attrs) + transform = attrs["transform"] + epsg = attrs["crs_epsg"] + + h, w, c = arr.shape + pixel_size = transform[0] + origin_e = transform[2] + origin_n = transform[5] + + # Pixel-centre coordinates + x_coords = origin_e + (np.arange(w) + 0.5) * pixel_size + y_coords = origin_n - (np.arange(h) + 0.5) * pixel_size + + data = np.asarray(arr[:]) + da = xr.DataArray( + data, + dims=["y", "x", "band"], + coords={ + "y": y_coords, + "x": x_coords, + "band": np.arange(c), + }, + ) + da = da.rio.write_crs(f"EPSG:{epsg}") + da = da.rio.write_transform(Affine(*transform)) + da = da.rio.set_spatial_dims(x_dim="x", y_dim="y") + return da + + +def build_mercator_pyramid( + store: "zarr.Group", + preview_name: str, + max_zoom: int = 12, + console: Optional["rich.console.Console"] = None, +) -> dict: + """Build Web Mercator pyramid from a UTM preview array using ndpyramid. + + Reads ``store[preview_name]`` (full-res RGBA uint8 in UTM), reprojects + to EPSG:3857 at multiple zoom levels, and writes each level to + ``{preview_name}_mercator/{zoom}`` with 256×256 chunks. + + Args: + store: Zarr group opened in ``r+`` mode. + preview_name: Source array name (``"rgb"`` or ``"pca_rgb"``). + max_zoom: Finest zoom level to generate (default 12). + console: Optional Rich Console for progress display. + + Returns: + Dict with keys: zoom_range, tile_bounds, levels_written. + Empty dict if source array is missing. + """ + from ndpyramid import pyramid_reproject + + # --- Validate source --- + try: + _ = store[preview_name] + except KeyError: + logger.warning("build_mercator_pyramid: %r not found in store", preview_name) + return {} + + # --- Compute zoom range from dataset metadata --- + attrs = dict(store.attrs) + pixel_size = attrs["transform"][0] + epsg = attrs["crs_epsg"] + + # Estimate centre latitude of dataset + transform = attrs["transform"] + origin_e = transform[2] + origin_n = transform[5] + h, w = store[preview_name].shape[:2] + center_e = origin_e + w * pixel_size / 2 + center_n = origin_n - h * pixel_size / 2 + + from pyproj import Transformer + to_wgs84 = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) + center_lon, center_lat = to_wgs84.transform(center_e, center_n) + + min_zoom, max_zoom_actual = _compute_mercator_zoom_range( + pixel_size, center_lat, max_zoom, + ) + num_levels = max_zoom_actual - min_zoom + 1 + + if console is not None: + console.print( + f" Mercator pyramid: z={min_zoom}–{max_zoom_actual} " + f"({num_levels} levels), centre={center_lat:.1f}°N" + ) + + # --- Delete existing mercator pyramid group --- + mercator_name = f"{preview_name}_mercator" + if mercator_name in store: + del store[mercator_name] + mercator_group = store.create_group(mercator_name) + + # --- Wrap UTM array as xarray DataArray --- + if console is not None: + console.print(f" Reading {preview_name} array into xarray...") + da = _utm_array_to_xarray(store, preview_name) + + # Convert to Dataset (ndpyramid expects Dataset) + ds = da.to_dataset(name=preview_name) + + # --- Generate pyramid via ndpyramid --- + if console is not None: + console.print(f" Running ndpyramid reproject (levels {min_zoom}–{max_zoom_actual})...") + + dt = pyramid_reproject( + ds, + levels=num_levels, + pixels_per_tile=256, + ) + + # --- Write each level to zarr store --- + tile_bounds = {} + levels_written = 0 + + for i in range(num_levels): + zoom = min_zoom + i + level_ds = dt[str(i)].ds + + level_da = level_ds[preview_name] + data = level_da.values # numpy array + + if data.ndim == 2: + # Single-band — expand to (y, x, 1) + data = data[:, :, np.newaxis] + elif data.ndim == 3 and data.shape[0] <= 4: + # ndpyramid may put band first: (band, y, x) → (y, x, band) + data = np.moveaxis(data, 0, -1) + + oh, ow = data.shape[:2] + nc = data.shape[2] if data.ndim == 3 else 1 + + # Ensure uint8 + if data.dtype != np.uint8: + data = np.clip(data, 0, 255).astype(np.uint8) + + chunk_h = min(256, oh) + chunk_w = min(256, ow) + + out_arr = mercator_group.create_array( + str(zoom), + shape=(oh, ow, nc), + chunks=(chunk_h, chunk_w, nc), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=None, + ) + out_arr[:] = data + + # Compute tile bounds: which XYZ tile indices this array covers + # ndpyramid level i at pixels_per_tile=256 covers specific tiles + n_tiles_y = oh // 256 + n_tiles_x = ow // 256 + + # For now store array dimensions; the client computes offsets + # from the spatial coordinates stored by ndpyramid + out_arr.attrs.update({ + "zoom": zoom, + "shape": [oh, ow, nc], + "n_tiles_y": n_tiles_y, + "n_tiles_x": n_tiles_x, + }) + + tile_bounds[zoom] = { + "n_tiles_y": n_tiles_y, + "n_tiles_x": n_tiles_x, + } + levels_written += 1 + + if console is not None: + console.print( + f" z={zoom}: {oh}x{ow} ({n_tiles_y}x{n_tiles_x} tiles)" + ) + + # --- Store summary attrs --- + mercator_group.attrs.update({ + "source": preview_name, + "zoom_min": min_zoom, + "zoom_max": max_zoom_actual, + "num_levels": levels_written, + "center_lon": float(center_lon), + "center_lat": float(center_lat), + "projection": "EPSG:3857", + }) + + return { + "zoom_range": [min_zoom, max_zoom_actual], + "tile_bounds": tile_bounds, + "levels_written": levels_written, + } +``` + +**Step 2: Test manually with an existing store** + +```bash +cd ~/src/git/ucam-eo/geotessera +uv run python -c " +import zarr +from geotessera.zarr_zone import build_mercator_pyramid +store = zarr.open_group('', mode='r+') +result = build_mercator_pyramid(store, 'rgb', max_zoom=12) +print(result) +" +``` + +Expected: prints zoom_range, tile_bounds dict, levels_written > 0. + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add build_mercator_pyramid using ndpyramid reproject" +``` + +--- + +## Task 3: Implement `add_mercator_pyramids_to_existing_store()` + +**Files:** +- Modify: `geotessera/zarr_zone.py:1506-1568` (replace `add_pyramids_to_existing_store`) + +**Step 1: Replace the function** + +Replace `add_pyramids_to_existing_store()` with: + +```python +def add_mercator_pyramids_to_existing_store( + store_path: Path, + max_zoom: int = 12, + workers: Optional[int] = None, + console: Optional["rich.console.Console"] = None, +) -> None: + """Add Web Mercator pyramids for all existing preview arrays. + + Opens the store at *store_path* in ``r+`` mode. For each preview + array that exists (``rgb``, ``pca_rgb``), generates Web Mercator + pyramids and updates store attributes. + + Deletes old UTM pyramids (``rgb_pyramid/``, ``pca_rgb_pyramid/``) + if present. + + Args: + store_path: Path to the ``.zarr`` directory. + max_zoom: Finest zoom level to generate (default 12). + workers: Unused (kept for API compat). ndpyramid handles parallelism. + console: Optional Rich Console for progress display. + """ + import zarr + + store = zarr.open_group(str(store_path), mode="r+") + attrs = dict(store.attrs) + + # Delete old UTM pyramids + for old_group in ["rgb_pyramid", "pca_rgb_pyramid"]: + if old_group in store: + del store[old_group] + if console is not None: + console.print(f" Deleted old {old_group}/") + + # Remove old pyramid attrs + old_attrs = [ + "has_rgb_pyramid", "rgb_pyramid_levels", + "has_pca_rgb_pyramid", "pca_rgb_pyramid_levels", + ] + for attr_name in old_attrs: + if attr_name in attrs: + del store.attrs[attr_name] + + # Build mercator pyramids for each preview + previews = [ + ("rgb", "has_rgb_preview"), + ("pca_rgb", "has_pca_preview"), + ] + + for preview_name, attr_flag in previews: + if not attrs.get(attr_flag, False): + continue + + if console is not None: + console.print(f" Building {preview_name} mercator pyramid...") + + result = build_mercator_pyramid( + store, preview_name, max_zoom=max_zoom, console=console, + ) + + if result and result.get("levels_written", 0) > 0: + store.attrs.update({ + f"has_{preview_name}_mercator": True, + f"{preview_name}_mercator_zoom_range": result["zoom_range"], + }) + + if console is not None: + zmin, zmax = result["zoom_range"] + console.print( + f" [green]{preview_name} mercator: " + f"z={zmin}–{zmax} ({result['levels_written']} levels)[/green]" + ) +``` + +**Step 2: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add add_mercator_pyramids_to_existing_store" +``` + +--- + +## Task 4: Update CLI to use mercator pyramids + +**Files:** +- Modify: `geotessera/registry_cli.py:2528` (import line) +- Modify: `geotessera/registry_cli.py:2636-2682` (pyramid-only handler) +- Modify: `geotessera/registry_cli.py:2679` (function call) + +**Step 1: Update the import** + +At line 2528, change: +```python +from .zarr_zone import build_zone_stores, add_rgb_to_existing_store, add_pca_to_existing_store, add_pyramids_to_existing_store +``` +to: +```python +from .zarr_zone import build_zone_stores, add_rgb_to_existing_store, add_pca_to_existing_store, add_mercator_pyramids_to_existing_store +``` + +**Step 2: Update the function call** + +At line 2679, change: +```python +add_pyramids_to_existing_store(store_path, workers=args.workers, console=console) +``` +to: +```python +add_mercator_pyramids_to_existing_store(store_path, console=console) +``` + +**Step 3: Update the `--pyramid` flag in `build_zone_stores` call** + +Find where `build_zone_stores` is called with `pyramid=args.pyramid` (around line 2700-2720). The `pyramid=True` parameter currently triggers UTM pyramid building during a full store build. Update the call site in `build_zone_stores` to call `build_mercator_pyramid` instead of `build_preview_pyramid`. This is at `zarr_zone.py:776-788`: + +Change: +```python +if pyramid: + for preview_name in ["rgb", "pca_rgb"]: + if preview_name in store: + levels = build_preview_pyramid( + store, preview_name, workers=workers, console=console, + ) + if levels > 0: + store.attrs.update({ + f"has_{preview_name}_pyramid": True, + f"{preview_name}_pyramid_levels": levels + 1, + }) + if console is not None: + console.print(f" [green]{preview_name} pyramid: {levels} levels[/green]") +``` + +to: +```python +if pyramid: + for preview_name in ["rgb", "pca_rgb"]: + if preview_name in store: + result = build_mercator_pyramid( + store, preview_name, console=console, + ) + if result and result.get("levels_written", 0) > 0: + store.attrs.update({ + f"has_{preview_name}_mercator": True, + f"{preview_name}_mercator_zoom_range": result["zoom_range"], + }) + if console is not None: + zmin, zmax = result["zoom_range"] + console.print(f" [green]{preview_name} mercator: z={zmin}-{zmax}[/green]") +``` + +**Step 4: Commit** + +```bash +git add geotessera/zarr_zone.py geotessera/registry_cli.py +git commit -m "feat: CLI uses mercator pyramids for --pyramid and --pyramid-only" +``` + +--- + +## Task 5: Remove old UTM pyramid code + +**Files:** +- Modify: `geotessera/zarr_zone.py` + +**Step 1: Delete old functions** + +Remove these functions entirely from `zarr_zone.py`: +- `_coarsen_strip()` (was around line 1313) +- `build_preview_pyramid()` (was around line 1326) + +They were replaced by `build_mercator_pyramid()` in Task 2. Also remove the old `PYRAMID_LEVELS` constant. + +Verify nothing else imports these: + +```bash +cd ~/src/git/ucam-eo/geotessera +grep -r "build_preview_pyramid\|_coarsen_strip\|PYRAMID_LEVELS" geotessera/ --include="*.py" +``` + +Expected: no matches (or only the lines you're about to delete). + +**Step 2: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "refactor: remove old UTM pyramid code (replaced by mercator)" +``` + +--- + +## Task 6: Update TypeScript types and reader + +**Files:** +- Modify: `~/src/git/ucam-eo/tze/packages/maplibre-zarr-tessera/src/types.ts` +- Modify: `~/src/git/ucam-eo/tze/packages/maplibre-zarr-tessera/src/zarr-reader.ts` + +**Step 1: Update StoreMetadata in types.ts** + +Replace the pyramid-related fields: + +```typescript +export interface StoreMetadata { + url: string; + utmZone: number; + epsg: number; + transform: [number, number, number, number, number, number]; + shape: [number, number, number]; + chunkShape: [number, number, number]; + nBands: number; + hasRgb: boolean; + hasPca: boolean; + pcaExplainedVariance?: number[]; + // Mercator pyramids (replaces hasRgbPyramid/hasPcaPyramid) + hasRgbMercator: boolean; + hasPcaMercator: boolean; + mercatorZoomRange: [number, number] | null; // [minZoom, maxZoom] + pyramidBasePixelSize: number; +} +``` + +**Step 2: Update zarr-reader.ts to open mercator arrays** + +Replace the pyramid detection section (lines 76-113) with mercator detection: + +```typescript +// Detect Web Mercator pyramid arrays +const rgbMercatorArrs = new Map>(); +const pcaMercatorArrs = new Map>(); +let hasRgbMercator = !!(attrs.has_rgb_mercator); +let hasPcaMercator = !!(attrs.has_pca_rgb_mercator); +const rgbMercZoomRange = (attrs.rgb_mercator_zoom_range as [number, number]) || null; +const pcaMercZoomRange = (attrs.pca_rgb_mercator_zoom_range as [number, number]) || null; +const mercatorZoomRange = rgbMercZoomRange ?? pcaMercZoomRange; + +if (hasRgbMercator && rgbMercZoomRange) { + for (let z = rgbMercZoomRange[0]; z <= rgbMercZoomRange[1]; z++) { + try { + const arr = await zarr.open(rootLoc.resolve(`rgb_mercator/${z}`), { kind: 'array' }); + rgbMercatorArrs.set(z, arr); + } catch { + break; + } + } + if (rgbMercatorArrs.size === 0) hasRgbMercator = false; +} + +if (hasPcaMercator && pcaMercZoomRange) { + for (let z = pcaMercZoomRange[0]; z <= pcaMercZoomRange[1]; z++) { + try { + const arr = await zarr.open(rootLoc.resolve(`pca_rgb_mercator/${z}`), { kind: 'array' }); + pcaMercatorArrs.set(z, arr); + } catch { + break; + } + } + if (pcaMercatorArrs.size === 0) hasPcaMercator = false; +} +``` + +Update the `meta` object: +```typescript +const meta: StoreMetadata = { + url, + utmZone, + epsg, + transform, + shape: embArr.shape as [number, number, number], + chunkShape: embArr.chunks as [number, number, number], + nBands: (embArr.shape[2] as number) || 128, + hasRgb, + hasPca, + pcaExplainedVariance: attrs.pca_explained_variance as number[] | undefined, + hasRgbMercator, + hasPcaMercator, + mercatorZoomRange, + pyramidBasePixelSize: Math.abs(transform[0]), +}; +``` + +Update the `ZarrStore` interface and return: +```typescript +export interface ZarrStore { + meta: StoreMetadata; + embArr: zarr.Array; + scalesArr: zarr.Array; + rgbArr: zarr.Array | null; + pcaArr: zarr.Array | null; + rgbMercatorArrs: Map>; + pcaMercatorArrs: Map>; + chunkManifest: Set | null; +} + +// ... return statement: +return { meta, embArr, scalesArr, rgbArr, pcaArr, rgbMercatorArrs, pcaMercatorArrs, chunkManifest }; +``` + +**Step 3: Commit** + +```bash +cd ~/src/git/ucam-eo/tze +git add packages/maplibre-zarr-tessera/src/types.ts packages/maplibre-zarr-tessera/src/zarr-reader.ts +git commit -m "feat: update types and reader for mercator pyramid arrays" +``` + +--- + +## Task 7: Simplify the tile handler in zarr-source.ts + +This is the big client-side payoff. Replace the broken UTM reprojection handler with a trivial mercator slice. + +**Files:** +- Modify: `~/src/git/ucam-eo/tze/packages/maplibre-zarr-tessera/src/zarr-source.ts` + +**Step 1: Replace handleTileRequest** + +Replace the entire `handleTileRequest` method (currently ~120 lines of UTM reprojection) with: + +```typescript +private async handleTileRequest( + params: { url: string }, + abortController: AbortController, +): Promise<{ data: ArrayBuffer }> { + const transparent = () => this.encodeRgbaToPng(new Uint8Array(256 * 256 * 4), 256, 256); + + if (!this.store || !this.proj) throw new Error('Store not initialized'); + + try { + // Parse z/x/y from URL: "zarr-xxx://{z}/{x}/{y}?t=..." + const urlPath = params.url.split('://')[1]?.split('?')[0]; + if (!urlPath) return { data: await transparent() }; + const parts = urlPath.split('/'); + const z = parseInt(parts[0], 10); + const x = parseInt(parts[1], 10); + const y = parseInt(parts[2], 10); + + // Pick the mercator array for this zoom level + const mode = this.opts.preview; + const mercArrs = mode === 'pca' + ? this.store.pcaMercatorArrs + : this.store.rgbMercatorArrs; + + // Find the best available zoom level (exact or nearest coarser) + let arr: typeof this.store.rgbArr = null; + let useZ = z; + while (useZ >= (this.store.meta.mercatorZoomRange?.[0] ?? 0)) { + const candidate = mercArrs.get(useZ); + if (candidate) { arr = candidate; break; } + useZ--; + } + if (!arr) return { data: await transparent() }; + + // Compute tile pixel offset within the array. + // ndpyramid's pyramid_reproject at level i covers the full world grid + // at that zoom level, but the stored array only covers the data extent. + // The array shape tells us how many pixels are stored. + const arrShape = arr.shape as number[]; + const arrH = arrShape[0]; + const arrW = arrShape[1]; + + // At zoom useZ, tiles are 256px. Compute how many tiles the array spans. + const nTilesY = Math.ceil(arrH / 256); + const nTilesX = Math.ceil(arrW / 256); + + // ndpyramid generates a global grid — we need to figure out + // which tile (x, y) at zoom useZ corresponds to array index (0, 0). + // The array attrs store this, or we compute from the stored coordinates. + const arrAttrs = arr.attrs as Record; + const tileOffsetX = (arrAttrs.tile_offset_x as number) ?? 0; + const tileOffsetY = (arrAttrs.tile_offset_y as number) ?? 0; + + // If zoom level was downgraded, scale tile coordinates + const zoomDiff = z - useZ; + const scaledX = Math.floor(x / Math.pow(2, zoomDiff)); + const scaledY = Math.floor(y / Math.pow(2, zoomDiff)); + + // Array-local tile indices + const ax = scaledX - tileOffsetX; + const ay = scaledY - tileOffsetY; + + if (ax < 0 || ay < 0 || ax >= nTilesX || ay >= nTilesY) { + return { data: await transparent() }; + } + + // Fetch the 256x256 chunk + const r0 = ay * 256; + const r1 = Math.min(r0 + 256, arrH); + const c0 = ax * 256; + const c1 = Math.min(c0 + 256, arrW); + + if (abortController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); + + const view = await fetchRegion(arr, [[r0, r1], [c0, c1], null]); + + if (abortController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); + + // Build RGBA tile + const tileW = 256, tileH = 256; + const rgba = new Uint8Array(tileW * tileH * 4); + const src = new Uint8Array(view.data.buffer, view.data.byteOffset, view.data.byteLength); + const srcH = r1 - r0; + const srcW = c1 - c0; + const nCh = view.shape.length >= 3 ? view.shape[2] : 3; + + for (let row = 0; row < srcH; row++) { + for (let col = 0; col < srcW; col++) { + const si = (row * srcW + col) * nCh; + const di = (row * tileW + col) * 4; + rgba[di] = src[si]; + rgba[di + 1] = src[si + 1] ?? 0; + rgba[di + 2] = src[si + 2] ?? 0; + rgba[di + 3] = (nCh >= 4) ? src[si + 3] : 255; + } + } + + return { data: await this.encodeRgbaToPng(rgba, tileW, tileH) }; + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + this.debug('error', `Tile render failed: ${(err as Error).message}`); + return { data: await transparent() }; + } +} +``` + +**Step 2: Remove dead code** + +Delete these methods/fields from `zarr-source.ts` as they are no longer used by the tile handler: + +- `pickPyramidLevel()` method +- `fetchSingleChunk()` method +- `fetchZarrChunks()` method +- `acquireFetchSlot()` / `releaseFetchSlot()` methods +- `zarrChunkCache` field and `MAX_ZARR_CACHE` constant +- `zarrChunkInflight` field +- `fetchQueue` / `fetchActive` / `MAX_CONCURRENT_FETCHES` fields +- `tileToLngLatBounds()` method (no longer needed — mercator tiles are pre-aligned) + +Keep: +- `pyramidMeta()` — still used by embedding code (`loadFullChunk`, `chunkPixelBounds`) +- `getPreviewArrayInfo()` — still used for level-0 preview queries (if any) +- `computeZoomRange()` — update to use `mercatorZoomRange` from metadata +- `reloadPreviewSource()` — still used for preview mode switching +- Everything related to embeddings, classification, overlays + +**Step 3: Update computeZoomRange()** + +Replace with: +```typescript +private computeZoomRange(): { minzoom: number; maxzoom: number } { + if (!this.store?.meta.mercatorZoomRange) return { minzoom: 0, maxzoom: 18 }; + const [minzoom, maxzoom] = this.store.meta.mercatorZoomRange; + // Allow MapLibre to overzoom 2 levels beyond stored data + return { minzoom, maxzoom: Math.min(22, maxzoom + 2) }; +} +``` + +**Step 4: Update addTo() source registration** + +In `addTo()`, after computing zoom range, the `map.addSource` call stays the same. But remove the `mlConfig.MAX_PARALLEL_IMAGE_REQUESTS = 6` line — MapLibre's defaults are fine when each tile is a single chunk fetch. + +**Step 5: Update references to old pyramid fields** + +Search for `hasRgbPyramid`, `hasPcaPyramid`, `pyramidLevels`, `rgbPyramidArrs`, `pcaPyramidArrs` in `zarr-source.ts` and replace with mercator equivalents. Key places: + +- First tile log message: update to show mercator zoom range +- `getPreviewArrayInfo()`: update to use mercator arrays for levels > 0 + - Actually, this method is only used by `fetchSingleChunk` which we're deleting. If it's also used by embedding code, keep it for level 0 only. + +**Step 6: Build and verify** + +```bash +cd ~/src/git/ucam-eo/tze +pnpm build +``` + +Expected: builds clean with no TypeScript errors. + +**Step 7: Commit** + +```bash +git add packages/maplibre-zarr-tessera/src/zarr-source.ts +git commit -m "feat: simplify tile handler to trivial mercator array slice + +Removes ~400 lines of UTM reprojection, chunk caching, concurrency +limiting, and pyramid level selection. Each tile is now a single +256x256 zarr chunk fetch with no coordinate math." +``` + +--- + +## Task 8: Update cram test for mercator pyramids + +**Files:** +- Modify: `~/src/git/ucam-eo/geotessera/tests/zarr.t` + +**Step 1: Update the pyramid test section** + +Replace the "Pyramid Building" test section (lines 171-215) with a mercator pyramid test: + +``` +Test: Mercator Pyramid Building on Preview Arrays +-------------------------------------------------- + +Build a zone store with RGB from the Cambridge tiles: + + $ geotessera-registry zarr-build \ + > "$TESTDIR/cb_tiles_zarr" \ + > --output-dir "$TESTDIR/zarr_mercator_test" \ + > --year 2024 \ + > --rgb 2>&1 | grep -E '(RGB preview|Zone)' | head -3 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + +Add mercator pyramids to the store: + + $ geotessera-registry zarr-build \ + > "$TESTDIR/cb_tiles_zarr" \ + > --output-dir "$TESTDIR/zarr_mercator_test" \ + > --pyramid-only 2>&1 | grep -E '(mercator|Mercator|Pyramids)' | head -3 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + +Verify mercator pyramid structure exists in the zarr store: + + $ ZARR_STORE=$(find "$TESTDIR/zarr_mercator_test" -name "*.zarr" -type d | head -1) + $ uv run python -c " + > import zarr + > store = zarr.open_group('$ZARR_STORE', mode='r') + > attrs = dict(store.attrs) + > print(f'has_rgb_mercator: {attrs.get(\"has_rgb_mercator\", False)}') + > zoom_range = attrs.get('rgb_mercator_zoom_range', None) + > print(f'zoom_range: {zoom_range}') + > mercator = store['rgb_mercator'] + > levels = sorted(k for k in mercator.keys()) + > print(f'levels: {levels}') + > first_level = mercator[levels[0]] + > print(f'first_level_shape: {first_level.shape}') + > print(f'first_level_chunks: {first_level.chunks}') + > " + has_rgb_mercator: True + zoom_range: * (glob) + levels: * (glob) + first_level_shape: * (glob) + first_level_chunks: * (glob) +``` + +**Step 2: Run tests** + +```bash +cd ~/src/git/ucam-eo/geotessera/tests +uv run cram zarr.t +``` + +Expected: all tests pass. + +**Step 3: Commit** + +```bash +git add tests/zarr.t +git commit -m "test: update cram test for mercator pyramids" +``` + +--- + +## Task 9: End-to-end visual verification + +**No code changes.** This task verifies the full pipeline works. + +**Step 1: Generate mercator pyramids for a real store** + +```bash +cd ~/src/git/ucam-eo/geotessera +geotessera-registry zarr-build \ + --output-dir \ + --pyramid-only +``` + +**Step 2: Serve and test in browser** + +```bash +cd ~/src/git/ucam-eo/tze +pnpm dev +``` + +Open browser, load a zone with mercator pyramids. Verify: + +1. Tiles appear at all zoom levels (z=6 through z=12) +2. Tiles align precisely with basemap features +3. Smooth zoom transitions (MapLibre cross-fades) +4. Preview mode switch (rgb ↔ pca) reloads tiles +5. Double-click still loads full-res embeddings +6. Classification overlays still work +7. No blank screens during zoom transitions +8. Basemap switcher still works (satellite/terrain/streets/dark) + +--- + +Plan complete and saved to `docs/plans/2026-02-26-mercator-pyramids-implementation.md`. Two execution options: + +**1. Subagent-Driven (this session)** — I dispatch fresh subagent per task, review between tasks, fast iteration + +**2. Parallel Session (separate)** — Open new session with executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/plans/2026-02-28-global-preview-implementation.md b/docs/plans/2026-02-28-global-preview-implementation.md new file mode 100644 index 0000000..ce70c81 --- /dev/null +++ b/docs/plans/2026-02-28-global-preview-implementation.md @@ -0,0 +1,1055 @@ +# Global Preview Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the broken `build_global_preview` with a chunk-aligned, dask-orchestrated, incrementally-updatable implementation that never races on zarr chunk writes. + +**Architecture:** Fixed 360x180 degree global grid at 0.0001 deg resolution. Each UTM zone is reprojected chunk-by-chunk (512x512 output tiles) using `dask.delayed` + `rasterio.warp.reproject`. Tasks write directly to non-overlapping zarr chunk positions, eliminating concurrent write races. Pyramid levels are updated incrementally per-zone. The store is created once and updated in place. + +**Tech Stack:** zarr v3, dask (delayed + threaded scheduler), rasterio, pyproj, numpy. Existing deps only. + +**Design doc:** `docs/plans/2026-02-28-global-preview-redesign.md` + +--- + +### Task 1: Add Constants and `_ensure_global_store()` + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add constants near top after line 30, add new function after line 1092) + +**Step 1: Add global grid constants after `RGB_PREVIEW_BANDS` (line 31)** + +```python +# Global preview grid (fixed extent, never changes) +GLOBAL_BOUNDS = (-180.0, -90.0, 180.0, 90.0) +GLOBAL_BASE_RES = 0.0001 # degrees (~10m at equator) +GLOBAL_LEVEL0_W = 3_600_000 # ceil(360 / 0.0001) +GLOBAL_LEVEL0_H = 1_800_000 # ceil(180 / 0.0001) +GLOBAL_CHUNK = 512 +GLOBAL_NUM_BANDS = 4 +GLOBAL_DEFAULT_LEVELS = 7 +GLOBAL_BATCH_CHUNK_ROWS = 64 # chunk-rows per dask compute batch +``` + +**Step 2: Add `_ensure_global_store()` function** + +Add after the `read_utm_region()` function (after line 1092). This function idempotently creates the global store with all pyramid levels pre-allocated: + +```python +def _ensure_global_store(store_path: Path, num_levels: int) -> None: + """Create the global preview store with fixed dimensions if it doesn't exist. + + If the store already exists with correct dimensions, this is a no-op. + Creates level 0 through level (num_levels-1), each with an 'rgb' array + and a 'band' coordinate array. + """ + import json as _json + + import zarr + from zarr.codecs import BloscCodec + + if store_path.exists(): + # Validate existing store has the right shape + root = zarr.open_group(str(store_path), mode="r") + if "0/rgb" in root: + shape = root["0/rgb"].shape + if shape == (GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W, GLOBAL_NUM_BANDS): + return # Store exists and is correct + raise ValueError( + f"Existing store has shape {shape}, expected " + f"({GLOBAL_LEVEL0_H}, {GLOBAL_LEVEL0_W}, {GLOBAL_NUM_BANDS})" + ) + + root = zarr.open_group(str(store_path), mode="w", zarr_format=3) + + h, w = GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W + band_data = np.arange(GLOBAL_NUM_BANDS, dtype=np.int32) + + for lvl in range(num_levels): + if h < 1 or w < 1: + break + # Create level group + level_dir = os.path.join(str(store_path), str(lvl)) + os.makedirs(level_dir, exist_ok=True) + group_meta = os.path.join(level_dir, "zarr.json") + if not os.path.exists(group_meta): + with open(group_meta, "w") as f: + _json.dump( + {"zarr_format": 3, "node_type": "group", "attributes": {}}, + f, + ) + # Re-open to pick up the group + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + + root.create_array( + f"{lvl}/rgb", + shape=(h, w, GLOBAL_NUM_BANDS), + chunks=(GLOBAL_CHUNK, GLOBAL_CHUNK, GLOBAL_NUM_BANDS), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=BloscCodec(cname="zstd", clevel=3), + dimension_names=["lat", "lon", "band"], + ) + root.create_array( + f"{lvl}/band", + data=band_data, + chunks=(GLOBAL_NUM_BANDS,), + ) + h //= 2 + w //= 2 + + # Write multiscales metadata + from topozarr.metadata import create_multiscale_metadata + + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + actual_levels = len([k for k in root.keys() if k.isdigit()]) + ms_attrs = create_multiscale_metadata(actual_levels, "EPSG:4326", "mean") + ms_attrs["multiscales"]["crs"] = "EPSG:4326" + west, south, east, north = GLOBAL_BOUNDS + ms_attrs["spatial"] = { + "bounds": [west, south, east, north], + "resolution": GLOBAL_BASE_RES, + } + root.attrs.update(ms_attrs) + zarr.consolidate_metadata(str(store_path)) +``` + +**Step 3: Verify it works with a quick smoke test** + +Run: +```bash +cd /Users/avsm/src/git/ucam-eo/geotessera +uv run python -c " +from pathlib import Path +import tempfile, shutil +from geotessera.zarr_zone import _ensure_global_store, GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W +tmp = Path(tempfile.mkdtemp()) / 'test.zarr' +_ensure_global_store(tmp, num_levels=3) +import zarr +r = zarr.open_group(str(tmp), mode='r') +print(f'level0 shape: {r[\"0/rgb\"].shape}') +print(f'level1 shape: {r[\"1/rgb\"].shape}') +print(f'level2 shape: {r[\"2/rgb\"].shape}') +print(f'band: {list(r[\"0/band\"][:].tolist())}') +print(f'bounds: {dict(r.attrs)[\"spatial\"][\"bounds\"]}') +shutil.rmtree(tmp.parent) +" +``` + +Expected output: +``` +level0 shape: (1800000, 3600000, 4) +level1 shape: (900000, 1800000, 4) +level2 shape: (450000, 900000, 4) +band: [0, 1, 2, 3] +bounds: [-180.0, -90.0, 180.0, 90.0] +``` + +**Step 4: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add global grid constants and _ensure_global_store()" +``` + +--- + +### Task 2: Implement `_reproject_chunk()` (single-chunk reprojection) + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add function after `_ensure_global_store`) + +**Step 1: Write `_reproject_chunk()`** + +This is the core unit of work. It reprojects one 512x512 output chunk from UTM source data and writes directly to the zarr array. It replaces `_reproject_tile()`. + +```python +def _reproject_chunk( + global_arr, + chunk_row: int, + chunk_col: int, + src_arr, + src_epsg: int, + src_pixel: float, + src_origin_e: float, + src_origin_n: float, + src_h: int, + src_w: int, + to_utm, +) -> bool: + """Reproject one 512x512 output chunk from UTM source and write to global_arr. + + Each call writes to a unique (chunk_row, chunk_col) position, so concurrent + calls to different positions are safe. + + Args: + global_arr: Zarr array handle for the global level-0 rgb array. + chunk_row: Output chunk row index (pixel row = chunk_row * GLOBAL_CHUNK). + chunk_col: Output chunk col index (pixel col = chunk_col * GLOBAL_CHUNK). + src_arr: Zarr array handle for the UTM zone's rgb array. + src_epsg: EPSG code of the source UTM zone. + src_pixel: Source pixel size in metres. + src_origin_e: Easting of source origin (top-left corner). + src_origin_n: Northing of source origin (top-left corner). + src_h: Source array height in pixels. + src_w: Source array width in pixels. + to_utm: pyproj.Transformer from EPSG:4326 to source EPSG. + + Returns: + True if any non-zero data was written, False if chunk was all-empty. + """ + from affine import Affine + from rasterio.enums import Resampling + import rasterio.warp + + west, south, east, north = GLOBAL_BOUNDS + row0 = chunk_row * GLOBAL_CHUNK + col0 = chunk_col * GLOBAL_CHUNK + tile_h = min(GLOBAL_CHUNK, GLOBAL_LEVEL0_H - row0) + tile_w = min(GLOBAL_CHUNK, GLOBAL_LEVEL0_W - col0) + if tile_h <= 0 or tile_w <= 0: + return False + + # Geographic extent of this output chunk + tile_west = west + col0 * GLOBAL_BASE_RES + tile_north = north - row0 * GLOBAL_BASE_RES + tile_east = tile_west + tile_w * GLOBAL_BASE_RES + tile_south = tile_north - tile_h * GLOBAL_BASE_RES + + dst_transform = Affine( + GLOBAL_BASE_RES, 0, tile_west, + 0, -GLOBAL_BASE_RES, tile_north, + ) + + # Back-project chunk corners to UTM to find source window + sample_lons = [tile_west, tile_east, tile_west, tile_east, + (tile_west + tile_east) / 2] + sample_lats = [tile_north, tile_north, tile_south, tile_south, + (tile_north + tile_south) / 2] + try: + utm_xs, utm_ys = to_utm.transform(sample_lons, sample_lats) + except Exception: + return False + + if any(not math.isfinite(v) for v in list(utm_xs) + list(utm_ys)): + return False + + # Compute source window with padding + pad = 16 + r_min = max(0, int((src_origin_n - max(utm_ys)) / src_pixel) - pad) + r_max = min(src_h, int(math.ceil( + (src_origin_n - min(utm_ys)) / src_pixel + )) + pad) + c_min = max(0, int((min(utm_xs) - src_origin_e) / src_pixel) - pad) + c_max = min(src_w, int(math.ceil( + (max(utm_xs) - src_origin_e) / src_pixel + )) + pad) + + if r_max <= r_min or c_max <= c_min: + return False + + window = np.asarray(src_arr[r_min:r_max, c_min:c_max, :]) + if not window.any(): + return False + + src_data = np.transpose(window.astype(np.float32), (2, 0, 1)) + del window + + win_transform = Affine( + src_pixel, 0, src_origin_e + c_min * src_pixel, + 0, -src_pixel, src_origin_n - r_min * src_pixel, + ) + + dst_data = np.full( + (GLOBAL_NUM_BANDS, tile_h, tile_w), np.nan, dtype=np.float32, + ) + + try: + rasterio.warp.reproject( + source=src_data, + destination=dst_data, + src_transform=win_transform, + src_crs=f"EPSG:{src_epsg}", + dst_transform=dst_transform, + dst_crs="EPSG:4326", + resampling=Resampling.average, + ) + except Exception: + return False + + del src_data + dst_data = np.nan_to_num(dst_data, nan=0.0) + dst_data = np.clip(dst_data, 0, 255).astype(np.uint8) + out = np.transpose(dst_data, (1, 2, 0)) # (tile_h, tile_w, 4) + del dst_data + + if not out.any(): + return False + + # Write directly to the global array at this chunk's position. + # This is the ONLY writer for this (chunk_row, chunk_col), so no races. + global_arr[row0 : row0 + tile_h, col0 : col0 + tile_w, :] = out + return True +``` + +**Step 2: Verify it compiles** + +Run: +```bash +uv run python -c "from geotessera.zarr_zone import _reproject_chunk; print('OK')" +``` + +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add _reproject_chunk() for chunk-aligned reprojection" +``` + +--- + +### Task 3: Implement `_reproject_zone()` (batched dask orchestration) + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add function after `_reproject_chunk`) + +**Step 1: Write `_reproject_zone()`** + +This function orchestrates reprojection of a single zone into the global store using dask.delayed in batches: + +```python +def _reproject_zone( + store_path: Path, + zone_num: int, + zone_store_path: Path, + zone_epsg: int, + zone_transform: list, + zone_shape: tuple, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> Tuple[int, int, int, int]: + """Reproject one zone's RGB into level 0 of the global store. + + Uses dask.delayed to parallelise chunk-level reprojection tasks. + Each task writes to a unique chunk position, eliminating write races. + Tasks are submitted in batches of GLOBAL_BATCH_CHUNK_ROWS chunk-rows + to bound dask graph size and provide progress feedback. + + Returns: + (row_start, row_end, col_start, col_end) of the affected region + in pixel coordinates, snapped to chunk boundaries. + """ + import dask + import zarr + from pyproj import Transformer + + src_pixel = zone_transform[0] + src_origin_e = zone_transform[2] + src_origin_n = zone_transform[5] + src_h, src_w = zone_shape[:2] + + west, south, east, north = GLOBAL_BOUNDS + + # Compute the zone's WGS84 bounding box from its metadata + to_4326 = Transformer.from_crs( + f"EPSG:{zone_epsg}", "EPSG:4326", always_xy=True, + ) + corners_utm = [ + (src_origin_e, src_origin_n), + (src_origin_e + src_w * src_pixel, src_origin_n), + (src_origin_e, src_origin_n - src_h * src_pixel), + (src_origin_e + src_w * src_pixel, src_origin_n - src_h * src_pixel), + ] + mid_e = src_origin_e + src_w * src_pixel / 2 + mid_n = src_origin_n - src_h * src_pixel / 2 + corners_utm += [ + (mid_e, src_origin_n), + (mid_e, src_origin_n - src_h * src_pixel), + (src_origin_e, mid_n), + (src_origin_e + src_w * src_pixel, mid_n), + ] + corners_4326 = [to_4326.transform(e, n) for e, n in corners_utm] + lons = [c[0] for c in corners_4326] + lats = [c[1] for c in corners_4326] + + zlon_min, zlon_max = min(lons), max(lons) + zlat_min, zlat_max = min(lats), max(lats) + + # Snap to chunk boundaries (expand outward) + col_start = max(0, (int(math.floor((zlon_min - west) / GLOBAL_BASE_RES)) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + col_end = min(GLOBAL_LEVEL0_W, + ((int(math.ceil((zlon_max - west) / GLOBAL_BASE_RES)) + + GLOBAL_CHUNK - 1) // GLOBAL_CHUNK * GLOBAL_CHUNK)) + row_start = max(0, (int(math.floor((north - zlat_max) / GLOBAL_BASE_RES)) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + row_end = min(GLOBAL_LEVEL0_H, + ((int(math.ceil((north - zlat_min) / GLOBAL_BASE_RES)) + + GLOBAL_CHUNK - 1) // GLOBAL_CHUNK * GLOBAL_CHUNK)) + + if col_end <= col_start or row_end <= row_start: + if console is not None: + console.print(f" [yellow]Zone {zone_num}: no output region[/yellow]") + return (0, 0, 0, 0) + + n_chunk_rows = (row_end - row_start) // GLOBAL_CHUNK + n_chunk_cols = (col_end - col_start) // GLOBAL_CHUNK + chunk_row_start = row_start // GLOBAL_CHUNK + chunk_col_start = col_start // GLOBAL_CHUNK + + if console is not None: + total_chunks = n_chunk_rows * n_chunk_cols + console.print( + f" Zone {zone_num:02d}: rows {row_start}-{row_end}, " + f"cols {col_start}-{col_end} " + f"({n_chunk_rows}x{n_chunk_cols} = {total_chunks} chunks)" + ) + + # Open zarr handles (shared across tasks — zarr handles are thread-safe + # for reads; writes to non-overlapping regions are safe) + global_root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + global_arr = global_root["0/rgb"] + zone_store = zarr.open_group(str(zone_store_path), mode="r") + src_arr = zone_store["rgb"] + + to_utm = Transformer.from_crs( + "EPSG:4326", f"EPSG:{zone_epsg}", always_xy=True, + ) + + # Process in batches of chunk-rows + chunks_written = 0 + chunks_total = n_chunk_rows * n_chunk_cols + + for batch_start in range(0, n_chunk_rows, GLOBAL_BATCH_CHUNK_ROWS): + batch_end = min(batch_start + GLOBAL_BATCH_CHUNK_ROWS, n_chunk_rows) + + tasks = [] + for cr_offset in range(batch_start, batch_end): + cr = chunk_row_start + cr_offset + for cc_offset in range(n_chunk_cols): + cc = chunk_col_start + cc_offset + task = dask.delayed(_reproject_chunk)( + global_arr=global_arr, + chunk_row=cr, + chunk_col=cc, + src_arr=src_arr, + src_epsg=zone_epsg, + src_pixel=src_pixel, + src_origin_e=src_origin_e, + src_origin_n=src_origin_n, + src_h=src_h, + src_w=src_w, + to_utm=to_utm, + ) + tasks.append(task) + + results = dask.compute(*tasks, scheduler="threads", + num_workers=workers) + batch_written = sum(1 for r in results if r) + chunks_written += batch_written + + if console is not None: + done = min((batch_end) * n_chunk_cols, chunks_total) + pct = int(100 * done / chunks_total) + console.print( + f" [{pct:3d}%] {done}/{chunks_total} chunks " + f"({chunks_written} with data)" + ) + + return (row_start, row_end, col_start, col_end) +``` + +**Step 2: Verify it compiles** + +Run: +```bash +uv run python -c "from geotessera.zarr_zone import _reproject_zone; print('OK')" +``` + +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add _reproject_zone() with batched dask orchestration" +``` + +--- + +### Task 4: Implement `_coarsen_zone_pyramid()` (incremental pyramid) + +**Files:** +- Modify: `geotessera/zarr_zone.py` (add function after `_reproject_zone`) + +**Step 1: Write `_coarsen_zone_pyramid()`** + +This updates pyramid levels 1-N for the region affected by a single zone: + +```python +def _coarsen_zone_pyramid( + store_path: Path, + row_start: int, + row_end: int, + col_start: int, + col_end: int, + num_levels: int, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> None: + """Update pyramid levels 1 through num_levels-1 for the affected region. + + Reads from the previous level and writes coarsened data to the current + level, processing in row-strips parallelised with dask.delayed. + + Args: + store_path: Path to the global zarr store. + row_start, row_end: Affected pixel row range at level 0. + col_start, col_end: Affected pixel col range at level 0. + num_levels: Total number of pyramid levels. + workers: Number of parallel workers. + """ + import dask + import zarr + + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + + prev_row_start, prev_row_end = row_start, row_end + prev_col_start, prev_col_end = col_start, col_end + + for lvl in range(1, num_levels): + prev_arr_path = f"{lvl - 1}/rgb" + cur_arr_path = f"{lvl}/rgb" + + if prev_arr_path not in root or cur_arr_path not in root: + break + + prev_arr = root[prev_arr_path] + cur_arr = root[cur_arr_path] + cur_h, cur_w = cur_arr.shape[:2] + + # Map the affected region to this level (halve coordinates) + # Snap to chunk boundaries at this level + lr_start = max(0, (prev_row_start // 2) // GLOBAL_CHUNK * GLOBAL_CHUNK) + lr_end = min(cur_h, ((prev_row_end // 2 + GLOBAL_CHUNK - 1) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + lc_start = max(0, (prev_col_start // 2) // GLOBAL_CHUNK * GLOBAL_CHUNK) + lc_end = min(cur_w, ((prev_col_end // 2 + GLOBAL_CHUNK - 1) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + + if lr_end <= lr_start or lc_end <= lc_start: + break + + if console is not None: + console.print( + f" Level {lvl}: rows {lr_start}-{lr_end}, " + f"cols {lc_start}-{lc_end}" + ) + + strip_h = GLOBAL_CHUNK + + def _coarsen_strip(r0, _prev_arr=prev_arr, _cur_arr=cur_arr, + _lc_start=lc_start, _lc_end=lc_end, + _cur_h=cur_h): + r1 = min(r0 + strip_h, _cur_h) + sr0 = r0 * 2 + sr1 = min(sr0 + (r1 - r0) * 2, _prev_arr.shape[0]) + src_w_region = (_lc_end - _lc_start) * 2 + sc0 = _lc_start * 2 + sc1 = min(sc0 + src_w_region, _prev_arr.shape[1]) + strip = np.asarray( + _prev_arr[sr0:sr1, sc0:sc1, :] + ).astype(np.float32) + th = strip.shape[0] // 2 + tw = strip.shape[1] // 2 + if th == 0 or tw == 0: + return + coarsened = ( + strip[: th * 2, : tw * 2, :] + .reshape(th, 2, tw, 2, GLOBAL_NUM_BANDS) + .mean(axis=(1, 3)) + ) + result = np.clip(coarsened, 0, 255).astype(np.uint8) + _cur_arr[r0 : r0 + th, _lc_start : _lc_start + tw, :] = result + + strip_starts = list(range(lr_start, lr_end, strip_h)) + tasks = [dask.delayed(_coarsen_strip)(r0) for r0 in strip_starts] + dask.compute(*tasks, scheduler="threads", num_workers=workers) + + # Prepare for next level + prev_row_start, prev_row_end = lr_start, lr_end + prev_col_start, prev_col_end = lc_start, lc_end +``` + +**Step 2: Verify it compiles** + +Run: +```bash +uv run python -c "from geotessera.zarr_zone import _coarsen_zone_pyramid; print('OK')" +``` + +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: add _coarsen_zone_pyramid() for incremental pyramid updates" +``` + +--- + +### Task 5: Rewrite `build_global_preview()` as orchestrator + +**Files:** +- Modify: `geotessera/zarr_zone.py` — replace the existing `build_global_preview()` function (lines 1094-1310) and delete `_reproject_tile()` (lines 1313-1424) and `_write_global_store()` (lines 1427-1817) + +**Step 1: Replace `build_global_preview()`** + +Delete the old `build_global_preview`, `_reproject_tile`, and `_write_global_store` functions. Replace with: + +```python +def build_global_preview( + zarr_dir: Path, + year: int, + zones: Optional[List[int]] = None, + num_levels: int = GLOBAL_DEFAULT_LEVELS, + workers: int = 4, + console: Optional["rich.console.Console"] = None, +) -> Path: + """Create or update global EPSG:4326 preview store from per-zone UTM stores. + + Reprojects each zone's RGB array from UTM to WGS84 into a fixed + global grid, then updates the pyramid levels for the affected region. + + The output store is always ``{zarr_dir}/global_rgb_{year}.zarr``. + If the store already exists, only the specified zones are re-processed + (incremental update). The store uses a fixed 360x180 degree extent + so adding zones never changes the array dimensions. + + Args: + zarr_dir: Directory containing per-zone ``.zarr`` stores. + year: Year to filter zone store filenames. + zones: Optional list of UTM zone numbers to include. If *None*, + all matching stores are used. + num_levels: Number of resolution levels in the output pyramid. + workers: Number of parallel reprojection workers. + console: Optional Rich Console for status messages. + + Returns: + Path to the global zarr store. + """ + import gc + + import zarr + + if console is not None: + console.print( + f"[bold]Building global preview (year={year})[/bold]" + ) + + # ------------------------------------------------------------------ + # 1. Discover zone stores + # ------------------------------------------------------------------ + pattern = re.compile(rf"^utm(\d{{2}})_{year}\.zarr$") + zone_stores: Dict[int, Path] = {} + + for entry in sorted(zarr_dir.iterdir()): + if not entry.is_dir(): + continue + m = pattern.match(entry.name) + if m is None: + continue + zone_num = int(m.group(1)) + if zones is not None and zone_num not in zones: + continue + zone_stores[zone_num] = entry + + if not zone_stores: + msg = f"No zone stores found in {zarr_dir} for year {year}" + if zones is not None: + msg += f" (zones filter: {zones})" + raise FileNotFoundError(msg) + + if console is not None: + console.print( + f" Found {len(zone_stores)} zone store(s): " + f"{sorted(zone_stores.keys())}" + ) + + # ------------------------------------------------------------------ + # 2. Read zone metadata (attrs only, no pixel data) + # ------------------------------------------------------------------ + zone_infos: Dict[int, dict] = {} + + for zone_num, store_path in sorted(zone_stores.items()): + store = zarr.open_group(str(store_path), mode="r") + attrs = dict(store.attrs) + + if "rgb" not in store: + if console is not None: + console.print( + f" [yellow]Zone {zone_num}: no rgb array, skipping[/yellow]" + ) + continue + + zone_infos[zone_num] = { + "store_path": store_path, + "epsg": int(attrs["crs_epsg"]), + "transform": list(attrs["transform"]), + "shape": store["rgb"].shape, + } + + if not zone_infos: + raise FileNotFoundError( + "No zone stores with rgb arrays found" + ) + + # ------------------------------------------------------------------ + # 3. Ensure the global store exists + # ------------------------------------------------------------------ + output_path = zarr_dir / f"global_rgb_{year}.zarr" + + if console is not None: + console.print(f" Output: {output_path}") + + _ensure_global_store(output_path, num_levels) + + if console is not None: + console.print( + f" Global grid: {GLOBAL_LEVEL0_W}x{GLOBAL_LEVEL0_H} " + f"@ {GLOBAL_BASE_RES} deg, {num_levels} levels" + ) + + # ------------------------------------------------------------------ + # 4. Reproject each zone and update pyramid + # ------------------------------------------------------------------ + for zone_num, zinfo in sorted(zone_infos.items()): + if console is not None: + console.print( + f"\n [bold]Processing zone {zone_num:02d} " + f"(EPSG:{zinfo['epsg']})[/bold]" + ) + + row_start, row_end, col_start, col_end = _reproject_zone( + store_path=output_path, + zone_num=zone_num, + zone_store_path=zinfo["store_path"], + zone_epsg=zinfo["epsg"], + zone_transform=zinfo["transform"], + zone_shape=zinfo["shape"], + workers=workers, + console=console, + ) + + if row_end <= row_start or col_end <= col_start: + continue + + if console is not None: + console.print(f" Building pyramid...") + + _coarsen_zone_pyramid( + store_path=output_path, + row_start=row_start, + row_end=row_end, + col_start=col_start, + col_end=col_end, + num_levels=num_levels, + workers=workers, + console=console, + ) + + gc.collect() + + # ------------------------------------------------------------------ + # 5. Re-consolidate metadata + # ------------------------------------------------------------------ + zarr.consolidate_metadata(str(output_path)) + + if console is not None: + console.print( + f"\n [bold green]Global store updated: {output_path}[/bold green]" + ) + console.print( + f" Zones processed: {sorted(zone_infos.keys())}" + ) + + return output_path +``` + +**Step 2: Verify no import errors** + +Run: +```bash +uv run python -c "from geotessera.zarr_zone import build_global_preview; print('OK')" +``` + +Expected: `OK` + +**Step 3: Commit** + +```bash +git add geotessera/zarr_zone.py +git commit -m "feat: rewrite build_global_preview() as incremental zone-by-zone orchestrator + +Replaces monolithic build with per-zone dask-orchestrated reprojection. +Deletes _reproject_tile() and _write_global_store()." +``` + +--- + +### Task 6: Update CLI in `registry_cli.py` + +**Files:** +- Modify: `geotessera/registry_cli.py` — update `global_preview_command()` (lines 2659-2700) and CLI parser (lines 3390-3430) + +**Step 1: Update `global_preview_command()`** + +The new signature drops `--output` (output is derived from zarr_dir + year): + +```python +def global_preview_command(args): + """Build global EPSG:4326 preview store from per-zone UTM stores.""" + from .zarr_zone import build_global_preview + + zarr_dir = Path(args.zarr_dir) + year = args.year + num_levels = args.levels + num_workers = args.workers + + # Parse zones into list of ints + zones = None + if args.zones: + try: + zones = [int(z.strip()) for z in args.zones.split(",")] + except ValueError: + console.print("[red]Error: --zones must be comma-separated integers[/red]") + return 1 + + console.print( + f"[bold]Building global preview store[/bold]\n" + f" Input: {zarr_dir}\n" + f" Year: {year}\n" + f" Levels: {num_levels}\n" + f" Workers: {num_workers}" + ) + if zones: + console.print(f" Zones: {', '.join(str(z) for z in zones)}") + + result = build_global_preview( + zarr_dir=zarr_dir, + year=year, + zones=zones, + num_levels=num_levels, + workers=num_workers, + console=console, + ) + + console.print(f"\n[bold green]Global preview store: {result}[/bold green]") + return 0 +``` + +**Step 2: Update CLI parser** + +Remove the `--output` argument from the parser. The output section +currently at lines 3400-3405 should be deleted: + +```python + # Remove these lines: + # global_preview_parser.add_argument( + # "--output", + # type=Path, + # required=True, + # help="Output path for global preview store", + # ) +``` + +**Step 3: Verify CLI parse works** + +Run: +```bash +uv run geotessera-registry global-preview --help +``` + +Expected: help text without `--output`, showing `zarr_dir`, `--year`, `--zones`, `--levels`, `--workers`. + +**Step 4: Commit** + +```bash +git add geotessera/registry_cli.py +git commit -m "feat: update global-preview CLI to drop --output flag + +Output path is now derived from zarr_dir + year. Supports incremental +updates by re-running with different --zones." +``` + +--- + +### Task 7: Update cram test in `tests/zarr.t` + +**Files:** +- Modify: `tests/zarr.t` (lines 185-220) + +**Step 1: Update the test** + +The test needs to drop the `--output` flag and adjust expectations for +the new fixed-grid store. The test uses a small zone store built from +Cambridge tiles, so the output will be a sparse global store. + +Replace lines 185-220 with: + +``` +Build global preview store from the zone store: + + $ geotessera-registry global-preview \ + > "$TESTDIR/zarr_global_test" \ + > --year 2024 \ + > --levels 3 \ + > --workers 2 2>&1 | grep -E '(Building|Found|Processing|Global store)' | head -4 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + * (glob) + +Verify global preview store structure has multiscales metadata: + + $ uv run python -c " + > import json + > with open('$TESTDIR/zarr_global_test/global_rgb_2024.zarr/zarr.json') as f: + > meta = json.load(f) + > ms = meta['attributes']['multiscales'] + > print(f'crs: {ms[\"crs\"]}') + > print(f'num_levels: {len(ms[\"layout\"])}') + > print(f'has_consolidated: {\"consolidated_metadata\" in meta}') + > cm = meta['consolidated_metadata']['metadata'] + > has_rgb = any('rgb' in k for k in cm) + > print(f'has_rgb_arrays: {has_rgb}') + > first_arr = [v for k, v in cm.items() if 'rgb' in k][0] + > has_blosc = any(c.get('name') == 'blosc' for c in first_arr.get('codecs', [])) + > print(f'has_blosc: {has_blosc}') + > print(f'dimension_names: {first_arr.get(\"dimension_names\")}') + > sp = meta['attributes']['spatial'] + > print(f'bounds: {sp[\"bounds\"]}') + > " + crs: EPSG:4326 + num_levels: 3 + has_consolidated: True + has_rgb_arrays: True + has_blosc: True + dimension_names: ['lat', 'lon', 'band'] + bounds: [-180.0, -90.0, 180.0, 90.0] +``` + +**Step 2: Run the cram test** + +Run: +```bash +cd /Users/avsm/src/git/ucam-eo/geotessera/tests && uv run cram zarr.t +``` + +The test will likely fail on the glob patterns for the console output — +update the glob lines based on actual output. The key assertion is that +`bounds: [-180.0, -90.0, 180.0, 90.0]` appears (fixed global extent) +and all other structural checks pass. + +**Step 3: Iterate on glob patterns until test passes** + +Cram tests use `*` and `(glob)` for flexible matching. Adjust the grep +filters and glob patterns based on the actual output from the new code. + +**Step 4: Commit** + +```bash +git add tests/zarr.t +git commit -m "test: update global-preview cram test for fixed global grid" +``` + +--- + +### Task 8: Clean up deleted code and verify end-to-end + +**Files:** +- Modify: `geotessera/zarr_zone.py` — verify `_reproject_tile` and `_write_global_store` are fully removed +- Verify: `scripts/patch_global_bounds.py` — still works (uses its own bounds computation, not affected) + +**Step 1: Verify old functions are gone** + +Run: +```bash +uv run python -c " +from geotessera import zarr_zone +for name in ['_reproject_tile', '_write_global_store']: + assert not hasattr(zarr_zone, name), f'{name} still exists!' +print('Old functions removed: OK') +" +``` + +Expected: `Old functions removed: OK` + +**Step 2: Run full cram test suite** + +Run: +```bash +cd /Users/avsm/src/git/ucam-eo/geotessera/tests && uv run cram *.t +``` + +All tests should pass. + +**Step 3: Verify `dask` is available as a dependency** + +Run: +```bash +uv run python -c "import dask; print(f'dask {dask.__version__}')" +``` + +If dask is not installed, add it: +```bash +uv add dask +``` + +**Step 4: Commit any final cleanup** + +```bash +git add -u +git commit -m "chore: clean up deleted functions and verify dependencies" +``` + +--- + +### Task 9: Smoke test with a real zone store (manual) + +This task is for manual verification, not automated. + +**Step 1: Run on a single zone** + +```bash +geotessera-registry global-preview /path/to/zarr/v0/ \ + --year 2025 --zones 30 --workers 4 +``` + +Verify: +- No OOM (watch with `top` or `htop`) +- Console shows progress per batch +- Output store exists at `/path/to/zarr/v0/global_rgb_2025.zarr` +- Pyramid levels 0-6 all have data in the zone 30 column range + +**Step 2: Run on a second zone (incremental)** + +```bash +geotessera-registry global-preview /path/to/zarr/v0/ \ + --year 2025 --zones 31 --workers 4 +``` + +Verify: +- Store was not recreated (level 0 shape unchanged) +- Zone 31's column range now has data +- Zone 30's data is still intact + +**Step 3: Verify in viewer** + +Load the store in the tze viewer and verify: +- No missing streaks +- Both zones render contiguously +- Pyramid levels display at appropriate zoom levels diff --git a/docs/plans/2026-02-28-global-preview-redesign.md b/docs/plans/2026-02-28-global-preview-redesign.md new file mode 100644 index 0000000..cc5ae6d --- /dev/null +++ b/docs/plans/2026-02-28-global-preview-redesign.md @@ -0,0 +1,291 @@ +# Global Preview Redesign + +## Problem + +The current `build_global_preview` generates corrupted output with missing +streaks of tiles. Root cause: multiple threads write to the same zarr chunk +concurrently via `ThreadPoolExecutor`. When two `_reproject_tile` tasks map +to overlapping 512x512 zarr chunks, one thread's partial write is +clobbered by the other's. The 512-pixel chunk boundaries create the +characteristic streak pattern. + +Secondary problems: +- Monolithic: must rebuild from scratch for every run +- No per-year separation in the pipeline design +- Bounds computed from zone union, not fixed — adding zones changes array shape +- Pyramid coarsening reads/writes full width strips, wasting memory + +## Design Goals + +1. **Correct**: no concurrent writes to the same chunk, ever +2. **Incremental**: process one UTM zone at a time, updating an existing store +3. **Memory-bounded**: O(workers x chunk_size) peak memory during reprojection +4. **Parallel**: use dask for task parallelism, rasterio releases the GIL +5. **Per-year**: each year gets its own store with stable dimensions + +## Fixed Global Grid + +The store covers the full globe with a fixed pixel grid: + +| Parameter | Value | +|-----------|-------| +| Bounds | (-180, -90, 180, 90) | +| Resolution | 0.0001 deg (~10m at equator) | +| Level 0 shape | 1,800,000 x 3,600,000 x 4 | +| Chunks | 512 x 512 x 4 | +| dtype | uint8 | +| fill_value | 0 (transparent black) | +| Pyramid levels | 7 (levels 0-6) | + +Pixel coordinate system: +- Column j -> longitude: -180 + j * 0.0001 +- Row i -> latitude: 90 - i * 0.0001 (north-up, row 0 = north pole) + +The fixed grid means: +- Array shape never changes regardless of which zones are processed +- Each UTM zone maps to a deterministic column range +- Unwritten chunks are sparse (zarr v3 doesn't store all-zero chunks) +- Adding a new zone is just filling in a region, no rebuild needed + +## Store Layout + +``` +global_rgb_{year}.zarr/ + zarr.json # multiscales + spatial.bounds (always [-180,-90,180,90]) + 0/ + rgb/ c/... # 1,800,000 x 3,600,000 x 4 + band/ c/0 # [0,1,2,3] int32 + 1/ + rgb/ c/... # 900,000 x 1,800,000 x 4 + band/ + ... + 6/ + rgb/ c/... # 28,125 x 56,250 x 4 + band/ +``` + +Output path: always `{zarr_dir}/global_rgb_{year}.zarr`. + +## Architecture + +### Per-Zone Processing + +Each zone is processed independently and sequentially: + +``` +for zone in zones: + 1. Read zone store metadata (attrs only) + 2. Compute zone's output region (chunk-aligned row/col slices) + 3. Reproject zone's RGB into level 0 of global store + 4. Update pyramid levels 1-6 for the affected region + 5. Re-consolidate metadata +``` + +Zones are processed sequentially to avoid any possibility of two zones +writing to the same chunk. Within each zone, reprojection of individual +chunks runs in parallel. + +### Phase 1: Reprojection (Level 0) + +For each zone: + +1. **Compute output region**: From the zone's EPSG code and store metadata, + determine which rows and columns of the global grid this zone covers. + Snap to chunk boundaries (round start down, end up to nearest multiple + of 512). + +2. **Batch chunk tasks**: Divide the output region into batches of + `BATCH_ROWS` chunk-rows (default 64). Each batch contains + `BATCH_ROWS x n_chunk_cols` chunk tasks. + +3. **Execute batch**: For each batch, use `dask.delayed` to create one + task per output chunk. Each task: + - Computes the chunk's WGS84 extent from its (row, col) position + - Back-projects corner points to UTM to find the source window + - Reads the source window from the UTM zarr store + - Calls `rasterio.warp.reproject()` for that 512x512 tile + - Writes the result directly to the global zarr array at the + chunk's position + + `dask.compute(*batch_tasks, scheduler='threads', num_workers=N)` + +4. **Memory per task**: ~6.5MB peak (source window + reproject buffers + + output chunk). With N workers: N x 6.5MB. With default 4 workers: 26MB. + +5. **Progress**: Report after each batch completes. + +**Why each task writes directly to zarr** (rather than building a dask +array and calling `to_zarr`): avoids zarr v3 compatibility issues with +dask's zarr integration, and makes the write-per-chunk guarantee explicit. +Since tasks write to non-overlapping chunk-aligned positions, there are no +races. + +### Phase 2: Pyramid Coarsening + +After a zone's level 0 is complete, update pyramid levels 1-6 for the +affected region: + +For each level L (1 through 6): +1. Determine which chunk-rows at level L are affected by this zone +2. Process in row strips: read 2 chunk-rows from level L-1, + coarsen 2x2 -> 1 pixel using mean, write 1 chunk-row to level L +3. Parallelize strips with `dask.delayed` (independent rows) + +Memory per strip: 2 x 512 x zone_width x 4 bytes. For a 6-degree zone +(60K pixels): ~234MB peak. Acceptable for a single strip. + +At each successive level, the zone's width halves, so coarsening gets +progressively cheaper. + +### Incremental Updates + +The store is created once via `_ensure_global_store()` which: +1. Checks if the store exists and has the correct shape +2. If not, creates it with the fixed global dimensions at all levels +3. Writes multiscales metadata and band coordinate arrays + +Subsequent runs for additional zones just write into the existing arrays. +Re-processing a zone overwrites its region (idempotent). + +The metadata (`spatial.bounds`) is always the fixed global extent, so +it never needs recomputation. + +## CLI Interface + +```bash +# Process one zone (creates store if needed) +geotessera-registry global-preview /data/zarr/v0/ \ + --year 2025 --zones 30 + +# Add more zones incrementally +geotessera-registry global-preview /data/zarr/v0/ \ + --year 2025 --zones 31,32 + +# Process all available zones for a year +geotessera-registry global-preview /data/zarr/v0/ --year 2025 + +# Control parallelism +geotessera-registry global-preview /data/zarr/v0/ \ + --year 2025 --zones 30 --workers 8 +``` + +The `--output` flag is removed. Output is always +`{zarr_dir}/global_rgb_{year}.zarr`. + +## Function Signatures + +```python +# Constants +GLOBAL_BOUNDS = (-180.0, -90.0, 180.0, 90.0) +BASE_RES = 0.0001 +LEVEL0_W = 3_600_000 +LEVEL0_H = 1_800_000 +CHUNK_SIZE = 512 +NUM_BANDS = 4 +NUM_LEVELS = 7 +BATCH_CHUNK_ROWS = 64 # chunk-rows per dask batch + + +def build_global_preview( + zarr_dir: Path, + year: int, + zones: Optional[List[int]] = None, + num_levels: int = NUM_LEVELS, + workers: int = 4, + console: Optional["rich.console.Console"] = None, +) -> Path: + """Create or update the global preview store for a given year.""" + + +def _ensure_global_store(store_path: Path, num_levels: int) -> None: + """Create the global store with fixed dimensions if it doesn't exist.""" + + +def _reproject_zone( + store_path: Path, + zone_num: int, + zone_store_path: Path, + zone_epsg: int, + zone_transform: list, + zone_shape: tuple, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> Tuple[int, int, int, int]: + """Reproject one zone's RGB into level 0. Returns affected region + as (row_start, row_end, col_start, col_end) in chunk coords.""" + + +def _reproject_chunk( + global_arr, # zarr array handle + chunk_row: int, + chunk_col: int, + src_arr, # zarr array handle (source UTM store) + src_epsg: int, + src_pixel: float, + src_origin_e: float, + src_origin_n: float, + src_h: int, + src_w: int, +) -> bool: + """Reproject one 512x512 output chunk. Writes directly to global_arr. + Returns True if any data was written.""" + + +def _coarsen_zone_pyramid( + store_path: Path, + row_start: int, row_end: int, + col_start: int, col_end: int, + num_levels: int, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> None: + """Update pyramid levels 1-N for the region affected by a zone.""" +``` + +## Edge Cases + +**Zone overlaps**: Adjacent UTM zones overlap by ~0.5 deg in longitude. +Both zones will write to the overlapping chunk-columns. Since zones are +processed sequentially, the later zone's data wins. This is acceptable +for preview imagery. + +**Partial chunks at zone edges**: The zone region is snapped to chunk +boundaries, so every task writes a complete 512x512 chunk. Source data +that falls outside the zone produces zeros (transparent), which is +correct fill. + +**Empty chunks**: Chunks over ocean or outside the zone's data coverage +contain all zeros. rasterio.warp.reproject produces NaN for out-of-bounds +pixels, which we convert to 0. zarr v3 skips writing all-zero chunks. + +**Anti-meridian**: UTM zone 1 starts at 180W and zone 60 ends at 180E. +The fixed grid handles this naturally since -180 and +180 are the +grid edges. + +## Memory Budget (worst case, 4 workers) + +| Phase | Per-task | Peak total | +|-------|----------|------------| +| Reproject | ~6.5 MB | 26 MB | +| Coarsen strip | ~234 MB | 234 MB | +| Dask graph | ~1 KB/node | ~7.5 MB/batch | +| **Total peak** | | **~270 MB** | + +## What Changes + +### Deleted +- `_reproject_tile()` — replaced by chunk-aligned `_reproject_chunk()` +- `_write_global_store()` — replaced by `_reproject_zone()` + `_coarsen_zone_pyramid()` +- Temporary store + shutil.move logic — writes directly to output store +- Bounds union computation — replaced by fixed `GLOBAL_BOUNDS` + +### New +- `_ensure_global_store()` — idempotent store creation +- `_reproject_chunk()` — chunk-aligned, self-contained reprojection +- `_coarsen_zone_pyramid()` — incremental pyramid update +- `BATCH_CHUNK_ROWS` constant — controls dask batch size + +### Modified +- `build_global_preview()` — simplified API (no `--output`), incremental semantics +- `global_preview_command()` — updated CLI args +- CLI parser — remove `--output`, keep `--year`, `--zones`, `--workers`, `--levels` diff --git a/geotessera/__init__.py b/geotessera/__init__.py index 438dd96..fdc7d84 100644 --- a/geotessera/__init__.py +++ b/geotessera/__init__.py @@ -53,6 +53,7 @@ from . import visualization from . import web from . import registry +from . import zarr_zone try: import importlib.metadata @@ -62,4 +63,4 @@ # Fallback for development installs __version__ = "unknown" -__all__ = ["GeoTessera", "dequantize_embedding", "visualization", "web", "registry"] +__all__ = ["GeoTessera", "dequantize_embedding", "visualization", "web", "registry", "zarr_zone"] diff --git a/geotessera/registry_cli.py b/geotessera/registry_cli.py index 2e92898..0b049ba 100644 --- a/geotessera/registry_cli.py +++ b/geotessera/registry_cli.py @@ -2523,6 +2523,560 @@ def file_check_command(args): return 0 +def zarr_build_command(args): + """Build zone-wide Zarr stores from local tile data.""" + from .zarr_zone import build_zone_stores, add_rgb_to_existing_store + from .registry import Registry + + base_dir = args.base_dir + output_dir = args.output_dir or os.path.join(base_dir, "zarr") + year = args.year + + # Handle --rgb-only mode: add RGB to existing stores + if args.rgb_only: + zarr_dir = Path(output_dir) + if not zarr_dir.is_dir(): + console.print(f"[red]Error: directory not found: {zarr_dir}[/red]") + return 1 + + # Parse zone filter for --rgb-only mode too + zone_filter = None + if args.zones: + try: + zone_filter = {int(z.strip()) for z in args.zones.split(",")} + except ValueError: + console.print("[red]Error: --zones must be comma-separated integers[/red]") + return 1 + + def _store_matches(p): + """Check if a .zarr store name matches the zone filter.""" + if zone_filter is None: + return True + # Store names are like utm30_2025.zarr + try: + zone_num = int(p.name.split("_")[0].replace("utm", "")) + return zone_num in zone_filter + except (ValueError, IndexError): + return True # include unrecognised names + + zarr_stores = sorted( + p for p in zarr_dir.iterdir() + if p.is_dir() and p.name.endswith(".zarr") and _store_matches(p) + ) + + if not zarr_stores: + console.print(f"[yellow]No .zarr stores found in {zarr_dir}[/yellow]") + return 1 + + console.print( + f"[bold]Adding RGB preview to existing stores[/bold]\n" + f" Directory: {zarr_dir}\n" + f" Stores: {len(zarr_stores)}" + ) + if zone_filter: + console.print(f" Zones: {', '.join(str(z) for z in sorted(zone_filter))}") + + for store_path in zarr_stores: + console.print(f"\n [cyan]{store_path.name}[/cyan]") + add_rgb_to_existing_store(store_path, workers=args.workers, console=console) + + console.print(f"\n[bold green]RGB preview added to {len(zarr_stores)} store(s)[/bold green]") + return 0 + + # Parse zone filter + zone_list = None + if args.zones: + try: + zone_list = [int(z.strip()) for z in args.zones.split(",")] + except ValueError: + console.print("[red]Error: --zones must be comma-separated integers[/red]") + return 1 + + console.print( + f"[bold]Building zone Zarr stores[/bold]\n" + f" Base dir: {base_dir}\n" + f" Output: {output_dir}\n" + f" Year: {year}" + ) + if zone_list: + console.print(f" Zones: {', '.join(str(z) for z in zone_list)}") + if args.dry_run: + console.print(" [dim](dry run)[/dim]") + if args.rgb: + console.print(" [green]+RGB preview[/green]") + + # Find registry directory: explicit flag, or auto-detect from base_dir + registry_dir = args.registry_dir + if registry_dir is None: + # Search base_dir and its parents for registry.parquet + candidate = Path(base_dir) + for _ in range(3): # check base_dir and up to 2 parents + if (candidate / "registry.parquet").exists(): + registry_dir = str(candidate) + break + candidate = candidate.parent + if registry_dir is None: + registry_dir = base_dir + if registry_dir != base_dir: + console.print(f" Registry: {registry_dir}") + + registry = Registry( + version=args.dataset_version, + embeddings_dir=base_dir, + registry_dir=registry_dir, + ) + + try: + import importlib.metadata + + gt_version = importlib.metadata.version("geotessera") + except Exception: + gt_version = "unknown" + + created = build_zone_stores( + registry=registry, + output_dir=Path(output_dir), + year=year, + zones=zone_list, + dry_run=args.dry_run, + geotessera_version=gt_version, + dataset_version=args.dataset_version, + console=console, + rgb=args.rgb, + workers=args.workers, + ) + + if not args.dry_run and created: + console.print(f"\n[bold green]Created {len(created)} Zarr store(s):[/bold green]") + for p in created: + console.print(f" {p}") + elif not args.dry_run and not created: + console.print("[yellow]No stores created (no matching tiles found)[/yellow]") + + return 0 + + +def global_preview_command(args): + """Build global EPSG:4326 preview store from per-zone UTM stores.""" + from .zarr_zone import build_global_preview + + zarr_dir = Path(args.zarr_dir) + year = args.year + num_levels = args.levels + num_workers = args.workers + + # Parse zones into list of ints + zones = None + if args.zones: + try: + zones = [int(z.strip()) for z in args.zones.split(",")] + except ValueError: + console.print("[red]Error: --zones must be comma-separated integers[/red]") + return 1 + + console.print( + f"[bold]Building global preview store[/bold]\n" + f" Input: {zarr_dir}\n" + f" Year: {year}\n" + f" Levels: {num_levels}\n" + f" Workers: {num_workers}" + ) + if zones: + console.print(f" Zones: {', '.join(str(z) for z in zones)}") + + result = build_global_preview( + zarr_dir=zarr_dir, + year=year, + zones=zones, + num_levels=num_levels, + workers=num_workers, + console=console, + ) + + console.print(f"\n[bold green]Global preview store written to {result}[/bold green]") + return 0 + + +def serve_command(args): + """Serve Zarr stores with a browser-based embedding viewer.""" + import http.server + import webbrowser + import importlib.resources + import shutil + import functools + + zarr_dir = os.path.abspath(args.zarr_dir) + port = args.port + + if not os.path.isdir(zarr_dir): + console.print(f"[red]Error: directory not found: {zarr_dir}[/red]") + return 1 + + # Find .zarr stores in the directory (or use --store if specified) + if args.store: + store_name = args.store + if not store_name.endswith(".zarr"): + store_name += ".zarr" + store_path = Path(zarr_dir) / store_name + if not store_path.is_dir(): + console.print(f"[red]Error: store not found: {store_path}[/red]") + return 1 + zarr_stores = [store_name] + else: + zarr_stores = sorted( + p.name for p in Path(zarr_dir).iterdir() + if p.is_dir() and p.name.endswith(".zarr") + ) + + if zarr_stores: + console.print(f"[bold]Serving Zarr stores from[/bold] {zarr_dir}") + for name in zarr_stores: + console.print(f" [cyan]{name}[/cyan]") + else: + console.print( + f"[yellow]Warning: no .zarr directories found in {zarr_dir}[/yellow]\n" + f" Serving anyway - you can point the viewer at any URL." + ) + + # Copy viewer HTML into the serving directory so it's accessible + viewer_html = importlib.resources.files("geotessera.viewer").joinpath("index.html") + viewer_dest = Path(zarr_dir) / "_viewer.html" + with importlib.resources.as_file(viewer_html) as src_path: + shutil.copy2(src_path, viewer_dest) + + # Write store manifest so the viewer can discover available stores + import json + + stores_manifest = Path(zarr_dir) / "_stores.json" + stores_manifest.write_text(json.dumps(zarr_stores)) + + # Generate chunk manifests so the viewer knows which chunks exist + # (avoids 404s for sparse stores where not all grid positions have data) + for store_name in zarr_stores: + store_path = Path(zarr_dir) / store_name + emb_chunks_dir = store_path / "embeddings" / "c" + if not emb_chunks_dir.is_dir(): + continue + chunks = [] + for row_dir in sorted(emb_chunks_dir.iterdir()): + if not row_dir.is_dir(): + continue + try: + row = int(row_dir.name) + except ValueError: + continue + for col_dir in sorted(row_dir.iterdir()): + if not col_dir.is_dir(): + continue + try: + col = int(col_dir.name) + except ValueError: + continue + if (col_dir / "0").exists(): + chunks.append([row, col]) + has_rgb = (store_path / "rgb" / "zarr.json").exists() + manifest_data = {"chunks": chunks, "has_rgb": has_rgb} + manifest_path = store_path / "_chunk_manifest.json" + manifest_path.write_text(json.dumps(manifest_data)) + rgb_label = " [green]+rgb[/green]" if has_rgb else "" + console.print(f" [dim]{store_name}: {len(chunks)} chunks indexed{rgb_label}[/dim]") + + class CORSHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=zarr_dir, **kw) + + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Range") + self.send_header( + "Access-Control-Expose-Headers", "Content-Length, Content-Range" + ) + super().end_headers() + + def do_OPTIONS(self): + self.send_response(200) + self.end_headers() + + def log_message(self, format, *a): + msg = format % a + if " 404 " in msg or " 500 " in msg: + console.print(f" [red]{msg}[/red]") + else: + console.print(f" [dim]{msg}[/dim]") + + if args.store: + viewer_url = f"http://localhost:{port}/_viewer.html?store={zarr_stores[0]}" + else: + viewer_url = f"http://localhost:{port}/_viewer.html" + + console.print(f"\n[bold green]Viewer:[/bold green] {viewer_url}") + console.print(f"[dim]Press Ctrl+C to stop[/dim]\n") + + if not args.no_open: + webbrowser.open(viewer_url) + + server = http.server.HTTPServer(("0.0.0.0", port), CORSHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + console.print("\n[dim]Stopped.[/dim]") + finally: + # Clean up temp files + for f in (viewer_dest, stores_manifest): + try: + f.unlink(missing_ok=True) + except Exception: + pass + + return 0 + + +# --------------------------------------------------------------------------- +# stac-index command +# --------------------------------------------------------------------------- + + +def _store_bbox_wgs84(attrs: dict) -> list[float]: + """Compute the WGS84 bounding box [west, south, east, north] from store attrs. + + Each store covers exactly one UTM zone, so longitude is determined directly + from the zone number. Latitude is derived by reprojecting the northing + extremes at the zone's central meridian. + """ + from pyproj import Transformer + + utm_zone = attrs["utm_zone"] + west = (utm_zone - 1) * 6 - 180 + east = utm_zone * 6 - 180 + + transform = attrs["transform"] + pixel_size = transform[0] + origin_northing = transform[5] + height_px = attrs["grid_height"] + epsg = attrs["crs_epsg"] + + n_max = origin_northing + n_min = origin_northing - height_px * pixel_size + + # Reproject northing at the central meridian (500 000 m false easting) + transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) + _, lats = transformer.transform([500_000, 500_000], [n_min, n_max]) + + return [west, min(lats), east, max(lats)] + + +def _zarr_store_to_stac_item( + store_path: Path, zarr_dir: Path +) -> "pystac.Item | None": + """Open a single Zarr store and return a pystac Item, or None on failure.""" + import zarr + import pystac + from datetime import datetime, timezone + + try: + store = zarr.open_group(str(store_path), mode="r") + attrs = dict(store.attrs) + except Exception as e: + logger.warning("Skipping %s: cannot open (%s)", store_path.name, e) + return None + + # Required attributes + required = ["utm_zone", "crs_epsg", "transform", "year"] + missing = [k for k in required if k not in attrs] + if missing: + logger.warning("Skipping %s: missing attrs %s", store_path.name, missing) + return None + + # Read grid dimensions from the scales array + try: + scales_shape = store["scales"].shape + attrs["grid_height"] = scales_shape[0] + attrs["grid_width"] = scales_shape[1] + except Exception as e: + logger.warning("Skipping %s: cannot read scales shape (%s)", store_path.name, e) + return None + + # Compute bounding box + try: + bbox = _store_bbox_wgs84(attrs) + except Exception as e: + logger.warning("Skipping %s: bbox computation failed (%s)", store_path.name, e) + return None + + year = attrs["year"] + item_id = store_path.name.removesuffix(".zarr") + dt = datetime(year, 1, 1, tzinfo=timezone.utc) + + # Build WGS84 polygon from bbox + west, south, east, north = bbox + geometry = { + "type": "Polygon", + "coordinates": [[ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ]], + } + + # Determine number of bands + try: + n_bands = store["embeddings"].shape[2] + except Exception: + n_bands = 128 + + properties = { + "utm_zone": attrs["utm_zone"], + "crs_epsg": attrs["crs_epsg"], + "pixel_size_m": attrs.get("pixel_size_m", attrs["transform"][0]), + "grid_width": attrs["grid_width"], + "grid_height": attrs["grid_height"], + "n_bands": n_bands, + "has_rgb_preview": attrs.get("has_rgb_preview", False), + "geotessera_version": attrs.get("geotessera_version", "unknown"), + } + + item = pystac.Item( + id=item_id, + geometry=geometry, + bbox=bbox, + datetime=dt, + properties=properties, + ) + + # Asset href is a placeholder — will be updated in stac_index_command + # once we know the item's JSON output location + item.add_asset( + "zarr", + pystac.Asset( + href=store_path.name, + media_type="application/x-zarr-v3", + roles=["data"], + title="Zarr v3 embedding store", + ), + ) + + return item + + +def stac_index_command(args): + """Scan a directory of Zarr stores and generate a static STAC catalog.""" + import pystac + from datetime import datetime, timezone + + zarr_dir = Path(args.zarr_dir).resolve() + output_dir = Path(args.output_dir).resolve() if args.output_dir else zarr_dir + + if not zarr_dir.is_dir(): + console.print(f"[red]Error:[/red] {zarr_dir} is not a directory") + return 1 + + # Discover .zarr stores matching utm{ZZ}_{YYYY}.zarr + import re + store_pattern = re.compile(r"^utm(\d{2})_(\d{4})\.zarr$") + store_paths = sorted( + p for p in zarr_dir.iterdir() + if p.is_dir() and store_pattern.match(p.name) + ) + + if not store_paths: + console.print(f"[red]Error:[/red] No utm*_*.zarr stores found in {zarr_dir}") + return 1 + + console.print(f"Found {len(store_paths)} Zarr store(s) in {zarr_dir}") + + # Build items grouped by year + items_by_year: dict[int, list["pystac.Item"]] = defaultdict(list) + skipped = 0 + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=console, + ) as progress: + task = progress.add_task("Reading store metadata", total=len(store_paths)) + for sp in store_paths: + item = _zarr_store_to_stac_item(sp, zarr_dir) + if item is not None: + year = item.datetime.year + # Fix asset href to be relative to the item's JSON location + # Item JSON will be at: output_dir/geotessera-{year}/{item.id}/{item.id}.json + item_json_dir = output_dir / f"geotessera-{year}" / item.id + zarr_asset = item.assets["zarr"] + zarr_asset.href = os.path.relpath(sp, item_json_dir) + items_by_year[year].append(item) + else: + skipped += 1 + progress.advance(task) + + if not items_by_year: + console.print("[red]Error:[/red] No valid stores found") + return 1 + + if skipped: + console.print(f"[yellow]Skipped {skipped} store(s) with errors[/yellow]") + + # Create root catalog + catalog = pystac.Catalog( + id="geotessera", + title="Geotessera Embedding Stores", + description="Static STAC catalog of Geotessera Zarr v3 embedding stores", + ) + + # Create one collection per year + for year in sorted(items_by_year): + items = items_by_year[year] + + # Union bounding box + all_bboxes = [it.bbox for it in items] + extent_bbox = [ + min(b[0] for b in all_bboxes), + min(b[1] for b in all_bboxes), + max(b[2] for b in all_bboxes), + max(b[3] for b in all_bboxes), + ] + + spatial_extent = pystac.SpatialExtent(bboxes=[extent_bbox]) + temporal_extent = pystac.TemporalExtent(intervals=[ + [ + datetime(year, 1, 1, tzinfo=timezone.utc), + datetime(year, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + ] + ]) + + collection = pystac.Collection( + id=f"geotessera-{year}", + title=f"Geotessera {year}", + description=f"Geotessera embedding stores for {year}", + extent=pystac.Extent(spatial=spatial_extent, temporal=temporal_extent), + ) + + for item in items: + collection.add_item(item) + + catalog.add_child(collection) + + # Normalize hrefs and save + catalog.normalize_hrefs(str(output_dir)) + catalog.save(catalog_type=pystac.CatalogType.SELF_CONTAINED) + + # Summary + total_items = sum(len(v) for v in items_by_year.values()) + console.print( + f"\n{emoji('✅ ')}STAC catalog written to {output_dir}\n" + f" {len(items_by_year)} collection(s), {total_items} item(s)" + ) + for year in sorted(items_by_year): + zones = sorted(it.properties["utm_zone"] for it in items_by_year[year]) + console.print(f" {year}: UTM zones {zones}") + + return 0 + + def main(): """Main entry point for the geotessera-registry CLI tool.""" # Configure logging with rich handler @@ -2628,6 +3182,15 @@ def main(): # - Show summary statistics by parquet file # - Useful for identifying duplicate embeddings across generation machines + # Serve Zarr stores with interactive browser viewer + geotessera-registry serve /path/to/zarr/ + + # This will: + # - Start a local HTTP server with CORS headers + # - Open a browser with the false-colour embedding viewer + # - Auto-detect .zarr stores in the directory + # - Use a different port: geotessera-registry serve /path/to/zarr -p 9000 + This tool is intended for GeoTessera data maintainers to generate the registry files that are distributed with the package. End users typically don't need to use this tool. @@ -2754,6 +3317,137 @@ def main(): ) file_scan_parser.set_defaults(func=file_scan_command) + # Zarr-build command + zarr_build_parser = subparsers.add_parser( + "zarr-build", + help="Build zone-wide Zarr stores from local tile data", + ) + zarr_build_parser.add_argument( + "base_dir", + help="Base directory containing downloaded tile data " + "(global_0.1_degree_representation/ and global_0.1_degree_tiff_all/)", + ) + zarr_build_parser.add_argument( + "--output-dir", + type=str, + default=None, + help="Output directory for Zarr stores (default: BASE_DIR/zarr)", + ) + zarr_build_parser.add_argument( + "--year", + type=int, + default=2024, + help="Year of embeddings to build (default: 2024)", + ) + zarr_build_parser.add_argument( + "--zones", + type=str, + default=None, + help="Comma-separated UTM zone numbers to build (e.g., '30' or '30,31'). " + "Useful for testing a single zone.", + ) + zarr_build_parser.add_argument( + "--dataset-version", + type=str, + default="v1", + help="Tessera dataset version (default: v1)", + ) + zarr_build_parser.add_argument( + "--registry-dir", + type=str, + default=None, + help="Directory containing registry.parquet and landmasks.parquet " + "(default: auto-detected from base_dir)", + ) + zarr_build_parser.add_argument( + "--dry-run", + action="store_true", + help="Show zone breakdown without building stores", + ) + zarr_build_parser.add_argument( + "--workers", + "-j", + type=int, + default=None, + help="Number of threads for parallel I/O (default: cpu_count, max 16)", + ) + zarr_build_parser.add_argument( + "--rgb", + action="store_true", + help="Generate RGB preview array during build (slow, can add later with --rgb-only)", + ) + zarr_build_parser.add_argument( + "--rgb-only", + action="store_true", + help="Add RGB preview to existing stores without rebuilding " + "(scans existing .zarr stores in output dir)", + ) + zarr_build_parser.set_defaults(func=zarr_build_command) + + # Global-preview command + global_preview_parser = subparsers.add_parser( + "global-preview", + help="Build global EPSG:4326 preview store from per-zone UTM stores", + ) + global_preview_parser.add_argument( + "zarr_dir", + type=Path, + help="Directory containing utm*_YYYY.zarr stores", + ) + global_preview_parser.add_argument( + "--year", + type=int, + default=2025, + help="Year to process (default: 2025)", + ) + global_preview_parser.add_argument( + "--zones", + type=str, + default=None, + help="Comma-separated UTM zones to include (default: all)", + ) + global_preview_parser.add_argument( + "--levels", + type=int, + default=7, + help="Number of multiscale levels (default: 7)", + ) + global_preview_parser.add_argument( + "--workers", + type=int, + default=4, + help="Number of parallel reprojection workers (default: 4)", + ) + global_preview_parser.set_defaults(func=global_preview_command) + + # Serve command + serve_parser = subparsers.add_parser( + "serve", + help="Serve Zarr stores with a browser-based false-colour embedding viewer", + ) + serve_parser.add_argument( + "zarr_dir", + help="Directory containing .zarr stores (e.g., the zarr/ output from zarr-build)", + ) + serve_parser.add_argument( + "--port", + "-p", + type=int, + default=8000, + help="Port number (default: 8000)", + ) + serve_parser.add_argument( + "--no-open", + action="store_true", + help="Don't automatically open the browser", + ) + serve_parser.add_argument( + "--store", + "-s", + help="Specific .zarr store to serve (skip discovery of all stores)", + ) + serve_parser.set_defaults(func=serve_command) + # File-check command file_check_parser = subparsers.add_parser( "file-check", @@ -2766,6 +3460,23 @@ def main(): ) file_check_parser.set_defaults(func=file_check_command) + # Stac-index command + stac_index_parser = subparsers.add_parser( + "stac-index", + help="Generate a static STAC catalog from a directory of Zarr stores", + ) + stac_index_parser.add_argument( + "zarr_dir", + help="Directory containing utm*_*.zarr stores", + ) + stac_index_parser.add_argument( + "--output-dir", + type=str, + default=None, + help="Output directory for STAC JSON files (default: same as zarr_dir)", + ) + stac_index_parser.set_defaults(func=stac_index_command) + args = parser.parse_args() if not args.command: diff --git a/geotessera/tiles.py b/geotessera/tiles.py index c40ad82..722039a 100644 --- a/geotessera/tiles.py +++ b/geotessera/tiles.py @@ -68,6 +68,8 @@ def load_embedding(self) -> np.ndarray: return self._load_from_geotiff() elif self._format == "zarr": return self._load_from_zarr() + elif self._format == "zone_zarr": + return self._load_from_zone_zarr() else: raise ValueError(f"Unknown format: {self._format}") @@ -233,6 +235,101 @@ def _load_spatial_metadata_from_zarr(self): self.height = da.rio.height self.width = da.rio.width + @classmethod + def from_zone_zarr( + cls, + zone_store_path: Path, + lon: float, + lat: float, + year: int, + ) -> "Tile": + """Create a Tile by reading from a zone-wide Zarr store. + + Extracts the tile-equivalent region from a zone store by looking up + the tile's landmask to determine its CRS, transform, and dimensions, + then reading the corresponding pixel region from the zone arrays. + + Args: + zone_store_path: Path to utm{zone}_{year}.zarr directory + lon: Tile center longitude + lat: Tile center latitude + year: Year of embeddings + + Returns: + Tile instance with data loaded from zone store + """ + import zarr + import math + + store = zarr.open_group(str(zone_store_path), mode="r") + attrs = dict(store.attrs) + + transform_list = attrs["transform"] + pixel_size = transform_list[0] + origin_easting = transform_list[2] + origin_northing = transform_list[5] + + # We need the tile's UTM coordinates to find its pixel region + # Read these from the landmask TIFF + import rasterio + from .registry import LANDMASKS_DIR_NAME, tile_to_landmask_filename + + # Try to find landmask in the parent directory structure + # The zone store is in output_dir/, landmasks are elsewhere + # For now, the caller must ensure the tile has spatial metadata set + # This factory constructs a Tile that loads data from the zone store + + tile = cls(lon, lat, year) + tile._format = "zone_zarr" + tile._zarr_path = Path(zone_store_path) + + # Store zone metadata for lazy loading + tile._zone_attrs = attrs + tile._zone_store_path = zone_store_path + + return tile + + def _load_from_zone_zarr(self) -> np.ndarray: + """Load dequantized embedding from a zone-wide Zarr store. + + Uses the tile's spatial metadata (set via landmask) to compute + the pixel region, then reads and dequantizes. + """ + import zarr + import math + from geotessera.core import dequantize_embedding + + store = zarr.open_group(str(self._zone_store_path), mode="r") + attrs = dict(store.attrs) + + transform_list = attrs["transform"] + pixel_size = transform_list[0] + origin_easting = transform_list[2] + origin_northing = transform_list[5] + + # Use tile spatial metadata to compute region + if self.transform is None: + raise ValueError( + "Tile spatial metadata not loaded. " + "Call _load_spatial_metadata_from_landmask() first." + ) + + tile_easting = self.transform.c + tile_northing = self.transform.f + + col_start = round((tile_easting - origin_easting) / pixel_size) + row_start = round((origin_northing - tile_northing) / pixel_size) + + h, w = self.height, self.width + embedding_int8 = np.asarray( + store["embeddings"][row_start : row_start + h, col_start : col_start + w, :] + ) + scales = np.asarray( + store["scales"][row_start : row_start + h, col_start : col_start + w] + ) + + return dequantize_embedding(embedding_int8, scales) + # ------------------------------------------------------------------------- # Convenience methods # ------------------------------------------------------------------------- diff --git a/geotessera/viewer/__init__.py b/geotessera/viewer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geotessera/viewer/index.html b/geotessera/viewer/index.html new file mode 100644 index 0000000..932e996 --- /dev/null +++ b/geotessera/viewer/index.html @@ -0,0 +1,1537 @@ + + + + + + Geotessera Viewer + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+

Geotessera

+
+

ZARR EMBEDDING VIEWER

+
+ + +
+ +
+ + +
+ +
+ + Manual URL + + +
+
Ready
+
+ + +
+ +
+
+ R + + 0 +
+
+ G + + 1 +
+
+ B + + 2 +
+
+
+ + +
+
+ + 0.80 +
+
+ +
+
+ + + + + +
+ +
+ + +
+
+ + + +
+ + +
+ -- +
+ + + + + + + + + + + + + diff --git a/geotessera/zarr_zone.py b/geotessera/zarr_zone.py new file mode 100644 index 0000000..ab51f5a --- /dev/null +++ b/geotessera/zarr_zone.py @@ -0,0 +1,1636 @@ +"""Zone-wide Zarr format for consolidated Tessera embeddings. + +This module provides tools for building and reading Zarr v3 stores that +consolidate all tiles within a UTM zone into a single store per year. +This enables efficient spatial subsetting and cloud-native access. + +Store layout (uncompressed): + utm{zone:02d}_{year}.zarr/ + embeddings # int8 (northing, easting, band) chunks=(1024, 1024, 128) + scales # float32 (northing, easting) chunks=(1024, 1024) + rgb # uint8 (northing, easting, rgba) chunks=(1024, 1024, 4) [optional] + +NaN in scales indicates no-data (water or no coverage). +Embeddings are high-entropy quantised values; compression gives negligible +benefit so we store uncompressed. +""" + +import logging +import math +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np + +logger = logging.getLogger(__name__) + +N_BANDS = 128 +RGB_PREVIEW_BANDS = (0, 1, 2) + +# Global preview grid (fixed extent, never changes) +GLOBAL_BOUNDS = (-180.0, -90.0, 180.0, 90.0) +GLOBAL_BASE_RES = 0.0001 # degrees (~10m at equator) +GLOBAL_LEVEL0_W = 3_600_000 # ceil(360 / 0.0001) +GLOBAL_LEVEL0_H = 1_800_000 # ceil(180 / 0.0001) +GLOBAL_CHUNK = 512 +GLOBAL_NUM_BANDS = 4 +GLOBAL_DEFAULT_LEVELS = 7 +GLOBAL_BATCH_CHUNK_ROWS = 64 # chunk-rows per dask compute batch + + +# ============================================================================= +# Data types +# ============================================================================= + + +@dataclass +class TileInfo: + """Metadata for a single tile to be placed in a zone store.""" + + lon: float + lat: float + year: int + epsg: int + transform: "rasterio.transform.Affine" + height: int + width: int + landmask_path: str + embedding_path: str + scales_path: str + + +@dataclass +class ZoneGrid: + """Describes the pixel grid for a single UTM zone store.""" + + zone: int + year: int + canonical_epsg: int + origin_easting: float + origin_northing: float + width_px: int + height_px: int + pixel_size: float = 10.0 + tiles: List[TileInfo] = field(default_factory=list) + + +# ============================================================================= +# Progress helpers — eliminate repeated console/no-console branching +# ============================================================================= + + +def _run_parallel(fn, items, workers, console=None, label="Processing"): + """Run fn(item) in a ThreadPoolExecutor, with optional Rich progress. + + Args: + fn: Callable that takes one item and returns a result. + items: Iterable of items to process. + workers: Number of threads. + console: Optional Rich Console for progress display. + label: Description for the progress bar. + + Returns: + List of (item, result) tuples for successful calls. + Failed calls are logged and skipped. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + items = list(items) + results = [] + + def _execute(pool): + futures = {pool.submit(fn, item): item for item in items} + for future in as_completed(futures): + item = futures[future] + try: + results.append((item, future.result())) + except Exception as e: + logger.warning(f"{label} failed for {item}: {e}") + yield item + + with ThreadPoolExecutor(max_workers=workers) as pool: + if console is not None: + from rich.progress import ( + Progress, SpinnerColumn, BarColumn, TextColumn, + MofNCompleteColumn, TimeElapsedColumn, + ) + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task(label, total=len(items)) + for _ in _execute(pool): + progress.advance(task) + else: + for _ in _execute(pool): + pass + + return results + + +# ============================================================================= +# UTM helpers +# ============================================================================= + + +def epsg_to_utm_zone(epsg: int) -> int: + """Extract UTM zone number from an EPSG code (326xx north, 327xx south).""" + if 32601 <= epsg <= 32660: + return epsg - 32600 + if 32701 <= epsg <= 32760: + return epsg - 32700 + raise ValueError(f"EPSG {epsg} is not a UTM zone (expected 326xx or 327xx)") + + +def epsg_is_south(epsg: int) -> bool: + """Check if an EPSG code is a southern hemisphere UTM zone.""" + return 32701 <= epsg <= 32760 + + +def zone_canonical_epsg(zone: int) -> int: + """Get the canonical (northern hemisphere) EPSG code for a UTM zone.""" + return 32600 + zone + + +def northing_to_canonical(northing: float, epsg: int) -> float: + """Convert a northing to canonical coordinates. + + Southern hemisphere tiles use a false northing of 10,000,000m. + We subtract this for a continuous axis. + """ + if epsg_is_south(epsg): + return northing - 10_000_000.0 + return northing + + +# ============================================================================= +# Grid computation +# ============================================================================= + + +def _snap_to_grid(value: float, pixel_size: float, snap_floor: bool) -> float: + """Snap a coordinate to the pixel grid.""" + if snap_floor: + return math.floor(value / pixel_size) * pixel_size + else: + return math.ceil(value / pixel_size) * pixel_size + + +def compute_zone_grid(tile_infos: List[TileInfo], year: int) -> ZoneGrid: + """Compute the zone-wide grid that encompasses all tiles.""" + if not tile_infos: + raise ValueError("No tiles provided") + + zone = epsg_to_utm_zone(tile_infos[0].epsg) + pixel_size = 10.0 + + min_easting = float("inf") + max_easting = float("-inf") + min_northing = float("inf") + max_northing = float("-inf") + + for ti in tile_infos: + ti_zone = epsg_to_utm_zone(ti.epsg) + if ti_zone != zone: + raise ValueError( + f"Mixed zones: expected {zone}, got {ti_zone} " + f"for tile ({ti.lon}, {ti.lat})" + ) + + tile_easting = ti.transform.c + tile_northing = northing_to_canonical(ti.transform.f, ti.epsg) + tile_right = tile_easting + ti.width * pixel_size + tile_bottom = tile_northing - ti.height * pixel_size + + min_easting = min(min_easting, tile_easting) + max_easting = max(max_easting, tile_right) + min_northing = min(min_northing, tile_bottom) + max_northing = max(max_northing, tile_northing) + + origin_easting = _snap_to_grid(min_easting, pixel_size, snap_floor=True) + origin_northing = _snap_to_grid(max_northing, pixel_size, snap_floor=False) + extent_right = _snap_to_grid(max_easting, pixel_size, snap_floor=False) + extent_bottom = _snap_to_grid(min_northing, pixel_size, snap_floor=True) + + return ZoneGrid( + zone=zone, + year=year, + canonical_epsg=zone_canonical_epsg(zone), + origin_easting=origin_easting, + origin_northing=origin_northing, + width_px=round((extent_right - origin_easting) / pixel_size), + height_px=round((origin_northing - extent_bottom) / pixel_size), + pixel_size=pixel_size, + tiles=tile_infos, + ) + + +def tile_pixel_offset( + tile_info: TileInfo, zone_grid: ZoneGrid +) -> Tuple[int, int]: + """Compute the (row, col) pixel offset of a tile within the zone grid.""" + tile_easting = tile_info.transform.c + tile_northing = northing_to_canonical(tile_info.transform.f, tile_info.epsg) + + col_start = round((tile_easting - zone_grid.origin_easting) / zone_grid.pixel_size) + row_start = round( + (zone_grid.origin_northing - tile_northing) / zone_grid.pixel_size + ) + return row_start, col_start + + +def compute_tile_grid(lon: float, lat: float, pixel_size: float = 10.0): + """Compute expected UTM EPSG, transform, and pixel dimensions for a tile. + + Derives the UTM zone from longitude, projects the tile's WGS84 corners + to UTM, and returns the expected grid parameters. + """ + from pyproj import Transformer + + zone = int(math.floor((lon + 180) / 6)) + 1 + zone = max(1, min(60, zone)) + is_south = lat < 0 + epsg = 32700 + zone if is_south else 32600 + zone + + west, east = lon - 0.05, lon + 0.05 + south, north = lat - 0.05, lat + 0.05 + + transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) + ul_e, ul_n = transformer.transform(west, north) + ur_e, ur_n = transformer.transform(east, north) + ll_e, ll_n = transformer.transform(west, south) + lr_e, lr_n = transformer.transform(east, south) + + origin_e = min(ul_e, ll_e) + origin_n = max(ul_n, ur_n) + max_e = max(ur_e, lr_e) + min_n = min(ll_n, lr_n) + + width = round((max_e - origin_e) / pixel_size) + height = round((origin_n - min_n) / pixel_size) + + transform_tuple = (pixel_size, 0.0, origin_e, 0.0, -pixel_size, origin_n) + return epsg, transform_tuple, height, width + + +# ============================================================================= +# Store creation & writing +# ============================================================================= + + +def _store_name(zone: int, year: int) -> str: + return f"utm{zone:02d}_{year}.zarr" + + +def create_zone_store( + zone_grid: ZoneGrid, + output_dir: Path, + geotessera_version: str = "unknown", + dataset_version: str = "v1", + include_rgb: bool = False, +) -> "zarr.Group": + """Create a new Zarr v3 store for a UTM zone.""" + import zarr + + store_path = output_dir / _store_name(zone_grid.zone, zone_grid.year) + + if store_path.exists(): + import shutil + shutil.rmtree(store_path) + + store = zarr.open_group(store_path, mode="w", zarr_format=3) + + store.create_array( + "embeddings", + shape=(zone_grid.height_px, zone_grid.width_px, N_BANDS), + chunks=(1024, 1024, N_BANDS), + dtype=np.int8, + fill_value=np.int8(0), + compressors=None, + dimension_names=["northing", "easting", "band"], + ) + store.create_array( + "scales", + shape=(zone_grid.height_px, zone_grid.width_px), + chunks=(1024, 1024), + dtype=np.float32, + fill_value=np.float32("nan"), + compressors=None, + dimension_names=["northing", "easting"], + ) + + # Optional preview arrays + for name in (["rgb"] if include_rgb else []): + store.create_array( + name, + shape=(zone_grid.height_px, zone_grid.width_px, 4), + chunks=(1024, 1024, 4), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=None, + dimension_names=["northing", "easting", "rgba"], + ) + + # Coordinate arrays + easting_coords = ( + zone_grid.origin_easting + + (np.arange(zone_grid.width_px) + 0.5) * zone_grid.pixel_size + ) + northing_coords = ( + zone_grid.origin_northing + - (np.arange(zone_grid.height_px) + 0.5) * zone_grid.pixel_size + ) + + for name, data, dim in [ + ("easting", easting_coords, "easting"), + ("northing", northing_coords, "northing"), + ("band", np.arange(N_BANDS, dtype=np.int32), "band"), + ]: + store.create_array( + name, shape=data.shape, dtype=data.dtype, + fill_value=0, compressors=None, dimension_names=[dim], + ) + store[name][:] = data + + # CRS WKT + try: + from pyproj import CRS + crs_wkt = CRS.from_epsg(zone_grid.canonical_epsg).to_wkt() + except ImportError: + crs_wkt = "" + + store.attrs.update({ + "utm_zone": zone_grid.zone, + "year": zone_grid.year, + "crs_epsg": zone_grid.canonical_epsg, + "crs_wkt": crs_wkt, + "transform": [ + zone_grid.pixel_size, 0.0, zone_grid.origin_easting, + 0.0, -zone_grid.pixel_size, zone_grid.origin_northing, + ], + "pixel_size_m": zone_grid.pixel_size, + "geotessera_version": geotessera_version, + "tessera_dataset_version": dataset_version, + "n_tiles": len(zone_grid.tiles), + }) + + return store + + +def write_tile_to_store( + store: "zarr.Group", + embedding_int8: np.ndarray, + scales: np.ndarray, + row_start: int, + col_start: int, +) -> None: + """Write a single tile's embedding and scales into the zone store.""" + h, w = scales.shape[:2] + store["embeddings"][row_start : row_start + h, col_start : col_start + w, :] = ( + embedding_int8 + ) + store["scales"][row_start : row_start + h, col_start : col_start + w] = scales + + +# ============================================================================= +# Landmask handling +# ============================================================================= + + +def apply_landmask_to_scales( + scales: np.ndarray, landmask_path: str +) -> np.ndarray: + """Apply landmask to scales, setting water pixels to NaN. + + If scales is 3D (H, W, 128), reduces to 2D (H, W) via nanmax. + """ + import rasterio + + if scales.ndim == 3: + scales = np.nanmax(scales, axis=2) + + scales = scales.astype(np.float32, copy=True) + + with rasterio.open(landmask_path) as src: + landmask = src.read(1) + + water_mask = landmask == 0 + + if water_mask.shape != scales.shape: + logger.warning( + f"Landmask shape {water_mask.shape} != scales shape {scales.shape}, " + f"skipping landmask for {landmask_path}" + ) + return scales + + scales[water_mask] = np.float32("nan") + return scales + + +# ============================================================================= +# Tile reading +# ============================================================================= + + +def _read_single_tile( + tile_info: TileInfo, + zone_grid: ZoneGrid, +) -> Tuple[np.ndarray, np.ndarray, int, int]: + """Read a single tile from disk and return (embedding, scales, row, col).""" + embedding = np.load(tile_info.embedding_path) + if embedding.ndim != 3 or embedding.shape[2] != N_BANDS: + raise ValueError( + f"Unexpected embedding shape {embedding.shape} for " + f"({tile_info.lon}, {tile_info.lat})" + ) + + scales = np.load(tile_info.scales_path) + scales = apply_landmask_to_scales(scales, tile_info.landmask_path) + + # Treat inf scales as no-data (same as NaN/water) + scales[~np.isfinite(scales)] = np.float32("nan") + + # Zero out embeddings where scales are NaN (water/no-data) + embedding[np.isnan(scales)] = 0 + + row_start, col_start = tile_pixel_offset(tile_info, zone_grid) + + h, w = embedding.shape[:2] + if ( + row_start < 0 + or col_start < 0 + or row_start + h > zone_grid.height_px + or col_start + w > zone_grid.width_px + ): + raise ValueError( + f"Tile ({tile_info.lon}, {tile_info.lat}) at offset " + f"({row_start}, {col_start}) with size ({h}, {w}) " + f"exceeds zone grid ({zone_grid.height_px}, {zone_grid.width_px})" + ) + + return embedding, scales, row_start, col_start + + +# ============================================================================= +# Tile info gathering +# ============================================================================= + + +def gather_tile_infos( + registry: "Registry", + year: int, + zones: Optional[List[int]] = None, + console: Optional["rich.console.Console"] = None, +) -> Dict[int, List[TileInfo]]: + """Gather tile metadata and group by UTM zone. + + Computes grid info deterministically from coordinates (no file I/O). + """ + from rasterio.transform import Affine + from .registry import ( + EMBEDDINGS_DIR_NAME, LANDMASKS_DIR_NAME, + tile_to_embedding_paths, tile_to_landmask_filename, + ) + + # Get tiles for this year from MultiIndex + gdf = registry._registry_gdf + try: + year_slice = gdf.loc[year] + tiles = [ + (year, lon_i / 100.0, lat_i / 100.0) + for lon_i, lat_i in year_slice.index.unique() + ] + except KeyError: + tiles = [] + + if console is not None: + console.print(f" Found {len(tiles):,} tiles for year {year}") + + zone_set = set(zones) if zones is not None else None + + # Pre-filter by UTM zone (deterministic from longitude) + if zone_set is not None: + before = len(tiles) + tiles = [ + (y, lon, lat) for y, lon, lat in tiles + if int(math.floor((lon + 180.0) / 6.0)) + 1 in zone_set + ] + if console is not None: + console.print( + f" Filtered to {len(tiles):,} tiles in zone(s) " + f"{','.join(str(z) for z in sorted(zone_set))} " + f"(skipped {before - len(tiles):,})" + ) + + # Build TileInfos using computed grid (no file I/O) + from pyproj import Transformer as ProjTransformer + + base_emb = str(registry._embeddings_dir / EMBEDDINGS_DIR_NAME) + base_lm = str(registry._embeddings_dir / LANDMASKS_DIR_NAME) + zones_dict: Dict[int, List[TileInfo]] = {} + transformer_cache: Dict[int, ProjTransformer] = {} + pixel_size = 10.0 + + for tile_year, tile_lon, tile_lat in tiles: + emb_rel, scales_rel = tile_to_embedding_paths(tile_lon, tile_lat, tile_year) + emb_path = os.path.join(base_emb, emb_rel) + scales_path = os.path.join(base_emb, scales_rel) + landmask_path = os.path.join( + base_lm, tile_to_landmask_filename(tile_lon, tile_lat) + ) + + # Compute EPSG and zone from coordinates + zone_num = int(math.floor((tile_lon + 180) / 6)) + 1 + zone_num = max(1, min(60, zone_num)) + if zone_set is not None and zone_num not in zone_set: + continue + is_south = tile_lat < 0 + epsg = 32700 + zone_num if is_south else 32600 + zone_num + + # Reuse cached transformer for this EPSG + if epsg not in transformer_cache: + transformer_cache[epsg] = ProjTransformer.from_crs( + "EPSG:4326", f"EPSG:{epsg}", always_xy=True + ) + proj = transformer_cache[epsg] + + # Project tile corners to UTM + west, east = tile_lon - 0.05, tile_lon + 0.05 + south, north = tile_lat - 0.05, tile_lat + 0.05 + ul_e, ul_n = proj.transform(west, north) + ur_e, _ = proj.transform(east, north) + ll_e, ll_n = proj.transform(west, south) + _, ur_n = proj.transform(east, north) + lr_e, lr_n = proj.transform(east, south) + + origin_e = min(ul_e, ll_e) + origin_n = max(ul_n, ur_n) + max_e = max(ur_e, lr_e) + min_n = min(ll_n, lr_n) + + width = round((max_e - origin_e) / pixel_size) + height = round((origin_n - min_n) / pixel_size) + tf_tuple = (pixel_size, 0.0, origin_e, 0.0, -pixel_size, origin_n) + + ti = TileInfo( + lon=tile_lon, lat=tile_lat, year=tile_year, epsg=epsg, + transform=Affine(*tf_tuple), + height=height, width=width, + landmask_path=landmask_path, + embedding_path=emb_path, scales_path=scales_path, + ) + zones_dict.setdefault(zone_num, []).append(ti) + + if console is not None: + total_matched = sum(len(t) for t in zones_dict.values()) + zone_summary = ", ".join( + f"zone {z}: {len(t)}" for z, t in sorted(zones_dict.items()) + ) + console.print(f" {total_matched} tiles in {len(zones_dict)} zone(s): {zone_summary}") + + return zones_dict + + +# ============================================================================= +# Orchestration +# ============================================================================= + + +def _default_workers() -> int: + """Return a sensible default thread count.""" + import multiprocessing + return min(multiprocessing.cpu_count(), 16) + + +def build_zone_stores( + registry: "Registry", + output_dir: Path, + year: int, + zones: Optional[List[int]] = None, + dry_run: bool = False, + geotessera_version: str = "unknown", + dataset_version: str = "v1", + console: Optional["rich.console.Console"] = None, + rgb: bool = True, + workers: Optional[int] = None, +) -> List[Path]: + """Build zone-wide Zarr stores from local tile data. + + Iterates all tiles for the given year, groups by UTM zone, and writes + one Zarr store per zone. + """ + if workers is None: + workers = _default_workers() + + output_dir = Path(output_dir) + + zones_dict = gather_tile_infos( + registry, year, zones=zones, console=console, + ) + + if not zones_dict: + if console is not None: + console.print(" [yellow]No tiles found matching criteria[/yellow]") + return [] + + if dry_run: + if console is not None: + from rich.table import Table + table = Table(show_header=True) + table.add_column("Zone", style="cyan", justify="right") + table.add_column("EPSG", style="dim") + table.add_column("Tiles", justify="right") + for zone_num, tile_infos in sorted(zones_dict.items()): + table.add_row( + str(zone_num), + str(zone_canonical_epsg(zone_num)), + str(len(tile_infos)), + ) + console.print(table) + return [] + + output_dir.mkdir(parents=True, exist_ok=True) + created_stores: List[Path] = [] + + from rich.progress import ( + Progress, SpinnerColumn, BarColumn, TextColumn, + MofNCompleteColumn, TimeElapsedColumn, + ) + + for zone_num, tile_infos in sorted(zones_dict.items()): + zone_grid = compute_zone_grid(tile_infos, year) + store_name = _store_name(zone_grid.zone, zone_grid.year) + + if console is not None: + grid_w_km = zone_grid.width_px * zone_grid.pixel_size / 1000 + grid_h_km = zone_grid.height_px * zone_grid.pixel_size / 1000 + console.print( + f" Zone {zone_num} " + f"[dim]EPSG:{zone_grid.canonical_epsg}[/dim] " + f"[dim]{zone_grid.width_px}x{zone_grid.height_px}px " + f"({grid_w_km:.0f}x{grid_h_km:.0f}km)[/dim] " + f"-> {store_name}" + ) + + store = create_zone_store( + zone_grid, output_dir, + geotessera_version=geotessera_version, + dataset_version=dataset_version, + include_rgb=rgb, + ) + + # Write tiles: read each tile from disk, write directly to store + errors = 0 + tiles_written = 0 + + if console is not None: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), + TextColumn("•"), + TextColumn("[dim]{task.fields[status]}", justify="left"), + console=console, + ) as progress: + task = progress.add_task( + "Writing tiles", total=len(tile_infos), status="starting...", + ) + for ti in tile_infos: + try: + emb, scales, row, col = _read_single_tile(ti, zone_grid) + write_tile_to_store(store, emb, scales, row, col) + tiles_written += 1 + except Exception as e: + logger.warning(f"Failed tile ({ti.lon}, {ti.lat}): {e}") + errors += 1 + progress.update(task, status=f"({ti.lon:.2f}, {ti.lat:.2f})") + progress.advance(task) + status = f"done ({tiles_written} tiles)" + if errors: + status += f" ({errors} errors)" + progress.update(task, status=status) + else: + for ti in tile_infos: + try: + emb, scales, row, col = _read_single_tile(ti, zone_grid) + write_tile_to_store(store, emb, scales, row, col) + tiles_written += 1 + except Exception as e: + logger.warning(f"Failed tile ({ti.lon}, {ti.lat}): {e}") + errors += 1 + + # Optional preview passes + if rgb: + stretch = compute_stretch_from_store(store, workers=workers, console=console) + if console is not None: + console.print( + f" RGB stretch: min={[f'{v:.2f}' for v in stretch['min']]}, " + f"max={[f'{v:.2f}' for v in stretch['max']]}" + ) + written = write_preview_pass( + store, "rgb", + lambda emb, sc: compute_rgb_chunk( + emb, sc, RGB_PREVIEW_BANDS, stretch["min"], stretch["max"] + ), + workers=workers, console=console, label="Writing RGB preview", + ) + store.attrs.update({ + "has_rgb_preview": True, + "rgb_bands": list(RGB_PREVIEW_BANDS), + "rgb_stretch": stretch, + }) + if console is not None: + console.print(f" [green]RGB preview: {written} chunks written[/green]") + + created_stores.append(output_dir / store_name) + + return created_stores + + +# ============================================================================= +# RGB preview +# ============================================================================= + + +def compute_rgb_chunk( + embedding_int8: np.ndarray, + scales: np.ndarray, + band_indices: tuple, + stretch_min: List[float], + stretch_max: List[float], +) -> np.ndarray: + """Compute an RGBA uint8 preview from embedding + scales.""" + h, w = scales.shape[:2] + rgba = np.zeros((h, w, 4), dtype=np.uint8) + valid = np.isfinite(scales) & (scales != 0) + scales_safe = np.where(valid, scales, 0.0) + + for i, band_idx in enumerate(band_indices): + raw = embedding_int8[:, :, band_idx].astype(np.float32) + dequant = raw * scales_safe + lo, hi = stretch_min[i], stretch_max[i] + normalised = (dequant - lo) / (hi - lo) + rgba[:, :, i] = np.clip(normalised * 255, 0, 255).astype(np.uint8) + + inv = ~valid + rgba[inv, :3] = 0 + rgba[:, :, 3] = np.where(valid, 255, 0).astype(np.uint8) + return rgba + + +def _sample_chunk_stats( + emb_arr, scales_arr, + ci: int, cj: int, + chunk_h: int, chunk_w: int, + emb_shape: tuple, + band_slice=None, + max_per_chunk: int = 10_000, +) -> Optional[np.ndarray]: + """Read one chunk and return subsampled dequantised values. + + Args: + band_slice: If given, read only these bands (e.g. slice(0,3) for RGB). + If None, read all bands. + + Returns: + (N, n_bands) float32 array, or None if chunk is empty. + """ + r0, r1 = ci * chunk_h, min(ci * chunk_h + chunk_h, emb_shape[0]) + c0, c1 = cj * chunk_w, min(cj * chunk_w + chunk_w, emb_shape[1]) + + scales_chunk = np.asarray(scales_arr[r0:r1, c0:c1]) + valid = np.isfinite(scales_chunk) & (scales_chunk != 0) + if not np.any(valid): + return None + + if band_slice is not None: + emb_chunk = np.asarray(emb_arr[r0:r1, c0:c1, band_slice]) + else: + emb_chunk = np.asarray(emb_arr[r0:r1, c0:c1, :]) + + vals = emb_chunk[valid].astype(np.float32) * scales_chunk[valid][:, np.newaxis] + + if vals.shape[0] > max_per_chunk: + rng = np.random.default_rng(ci * 10007 + cj) + idx = rng.choice(vals.shape[0], max_per_chunk, replace=False) + vals = vals[idx] + + return vals + + +def compute_stretch_from_store( + store: "zarr.Group", + p_low: float = 2, + p_high: float = 98, + workers: int = 8, + console: Optional["rich.console.Console"] = None, +) -> dict: + """Compute percentile stretch for RGB bands from an existing store.""" + emb_arr = store["embeddings"] + scales_arr = store["scales"] + emb_shape = emb_arr.shape + chunk_h, chunk_w = emb_arr.chunks[:2] + n_rows = math.ceil(emb_shape[0] / chunk_h) + n_cols = math.ceil(emb_shape[1] / chunk_w) + + band_slice = slice(RGB_PREVIEW_BANDS[0], RGB_PREVIEW_BANDS[-1] + 1) + chunk_indices = [(ci, cj) for ci in range(n_rows) for cj in range(n_cols)] + + results = _run_parallel( + lambda idx: _sample_chunk_stats( + emb_arr, scales_arr, idx[0], idx[1], + chunk_h, chunk_w, emb_shape, + band_slice=band_slice, + ), + chunk_indices, workers, console, + label=f"Computing stretch ({workers} threads)", + ) + + samples = [r for _, r in results if r is not None] + if not samples: + return {"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0]} + + all_rgb = np.concatenate(samples, axis=0) + stretch_min = [float(np.percentile(all_rgb[:, i], p_low)) for i in range(3)] + stretch_max = [float(np.percentile(all_rgb[:, i], p_high)) for i in range(3)] + + for i in range(3): + if stretch_max[i] <= stretch_min[i]: + stretch_max[i] = stretch_min[i] + 1.0 + + return {"min": stretch_min, "max": stretch_max} + + + +# ============================================================================= +# Generic preview pass +# ============================================================================= + + +def write_preview_pass( + store: "zarr.Group", + array_name: str, + compute_fn, + workers: int = 8, + console: Optional["rich.console.Console"] = None, + label: str = "Writing preview", +) -> int: + """Write a preview array by iterating over chunks in parallel. + + For each chunk, reads embeddings + scales, calls compute_fn(emb, scales) + to get an RGBA array, and writes it to store[array_name]. + + Args: + store: Zarr group with embeddings, scales, and the target array. + array_name: Name of the output array (e.g. "rgb"). + compute_fn: Callable(emb_int8, scales_f32) -> rgba_uint8. + workers: Number of threads. + console: Optional Rich Console for progress. + label: Description for the progress bar. + + Returns: + Number of chunks written. + """ + emb_arr = store["embeddings"] + scales_arr = store["scales"] + out_arr = store[array_name] + + emb_shape = emb_arr.shape + chunk_h, chunk_w = emb_arr.chunks[:2] + n_rows = math.ceil(emb_shape[0] / chunk_h) + n_cols = math.ceil(emb_shape[1] / chunk_w) + + def _process_chunk(idx): + ci, cj = idx + r0, r1 = ci * chunk_h, min(ci * chunk_h + chunk_h, emb_shape[0]) + c0, c1 = cj * chunk_w, min(cj * chunk_w + chunk_w, emb_shape[1]) + + scales_chunk = np.asarray(scales_arr[r0:r1, c0:c1]) + if np.all(~np.isfinite(scales_chunk) | (scales_chunk == 0)): + return False + + emb_chunk = np.asarray(emb_arr[r0:r1, c0:c1, :]) + rgba = compute_fn(emb_chunk, scales_chunk) + out_arr[r0:r1, c0:c1, :] = rgba + return True + + chunk_indices = [(ci, cj) for ci in range(n_rows) for cj in range(n_cols)] + + results = _run_parallel( + _process_chunk, chunk_indices, workers, console, + label=f"{label} ({workers} threads)", + ) + + return sum(1 for _, wrote in results if wrote) + + +# ============================================================================= +# Standalone preview commands (--rgb-only) +# ============================================================================= + + +def add_rgb_to_existing_store( + store_path: Path, + workers: Optional[int] = None, + console: Optional["rich.console.Console"] = None, +) -> None: + """Add RGB preview array to an existing Zarr store.""" + import zarr + + store = zarr.open_group(str(store_path), mode="r+") + + try: + _ = store["rgb"] + except KeyError: + emb_shape = store["embeddings"].shape + store.create_array( + "rgb", + shape=(emb_shape[0], emb_shape[1], 4), + chunks=(1024, 1024, 4), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=None, + dimension_names=["northing", "easting", "rgba"], + ) + + if workers is None: + workers = _default_workers() + + if console is not None: + console.print(f" Pass 1: Computing band statistics ({workers} threads)...") + + stretch = compute_stretch_from_store(store, workers=workers, console=console) + if console is not None: + console.print(f" Stretch: min={stretch['min']}, max={stretch['max']}") + console.print(" Pass 2: Writing RGB preview...") + + written = write_preview_pass( + store, "rgb", + lambda emb, sc: compute_rgb_chunk( + emb, sc, RGB_PREVIEW_BANDS, stretch["min"], stretch["max"] + ), + workers=workers, console=console, label="Writing RGB preview", + ) + + store.attrs.update({ + "has_rgb_preview": True, + "rgb_bands": list(RGB_PREVIEW_BANDS), + "rgb_stretch": stretch, + }) + + if console is not None: + console.print(f" [green]RGB preview: {written} chunks written[/green]") + + + +def _utm_array_to_xarray( + store: "zarr.Group", array_name: str +) -> "xarray.DataArray": + """Wrap a UTM zarr preview array as a georeferenced xarray DataArray. + + Uses dask to lazily reference the zarr array without loading it all + into memory. Constructs pixel-centre coordinates from the store's + affine transform and attaches CRS metadata via rioxarray. + + The returned DataArray has dims ``('band', 'y', 'x')`` — the order + required by rioxarray / rasterio for reprojection. + + Args: + store: Zarr group containing the array and transform/CRS attrs. + array_name: Name of the preview array (e.g. ``"rgb"``). + + Returns: + Dask-backed georeferenced xarray DataArray ready for reprojection. + """ + import dask.array as da + import rioxarray # noqa: F401 — registers .rio accessor + import xarray as xr + from affine import Affine + + arr = store[array_name] + # Lazy dask array — no data loaded into memory yet + dask_arr = da.from_zarr(arr) # (northing, easting, band) + + store_attrs = dict(store.attrs) + transform = list(store_attrs["transform"]) + epsg = int(store_attrs["crs_epsg"]) + + pixel_size = transform[0] + origin_e = transform[2] + origin_n = transform[5] + + h, w, nc = arr.shape + + # Pixel-centre coordinates + x_coords = origin_e + (np.arange(w) + 0.5) * pixel_size + y_coords = origin_n - (np.arange(h) + 0.5) * pixel_size + + # Transpose to (band, y, x) as required by rioxarray + dask_byx = da.transpose(dask_arr, (2, 0, 1)) + + xda = xr.DataArray( + dask_byx, + dims=["band", "y", "x"], + coords={ + "y": y_coords, + "x": x_coords, + }, + ) + xda = xda.rio.write_crs(f"EPSG:{epsg}") + xda = xda.rio.write_transform(Affine(*transform)) + xda = xda.rio.set_spatial_dims(x_dim="x", y_dim="y") + + return xda + + +# ============================================================================= +# Reading support +# ============================================================================= + + +def open_zone_store(path) -> "xarray.Dataset": + """Open a zone Zarr store as an xarray Dataset.""" + import xarray as xr + return xr.open_zarr(str(path)) + + +def read_region_from_zone( + path, + bbox: Tuple[float, float, float, float], +) -> Tuple[np.ndarray, np.ndarray, dict]: + """Read a spatial subset from a zone store. + + Args: + path: Path to the .zarr directory + bbox: (min_easting, min_northing, max_easting, max_northing) in UTM + + Returns: + (embeddings_int8, scales_float32, attrs) + """ + import zarr + + store = zarr.open_group(str(path), mode="r") + attrs = dict(store.attrs) + + transform = attrs["transform"] + pixel_size = transform[0] + origin_easting = transform[2] + origin_northing = transform[5] + + min_e, min_n, max_e, max_n = bbox + + col_start = max(0, int(math.floor((min_e - origin_easting) / pixel_size))) + col_end = min( + store["scales"].shape[1], + int(math.ceil((max_e - origin_easting) / pixel_size)), + ) + row_start = max(0, int(math.floor((origin_northing - max_n) / pixel_size))) + row_end = min( + store["scales"].shape[0], + int(math.ceil((origin_northing - min_n) / pixel_size)), + ) + + embeddings = store["embeddings"][row_start:row_end, col_start:col_end, :] + scales = store["scales"][row_start:row_end, col_start:col_end] + + return np.asarray(embeddings), np.asarray(scales), attrs + + +def _ensure_global_store(store_path: Path, num_levels: int) -> None: + """Create the global preview store with fixed dimensions if it doesn't exist. + + If the store already exists with correct dimensions, this is a no-op. + Creates level 0 through level (num_levels-1), each with an 'rgb' array + and a 'band' coordinate array. + """ + import json as _json + import zarr + from zarr.codecs import BloscCodec + + if store_path.exists(): + root = zarr.open_group(str(store_path), mode="r") + if "0/rgb" in root: + shape = root["0/rgb"].shape + if shape == (GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W, GLOBAL_NUM_BANDS): + return + # Old-format store with different dimensions — replace it + import shutil + logger.warning( + "Existing store %s has shape %s (expected %s), " + "replacing with fixed global grid", + store_path, shape, + (GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W, GLOBAL_NUM_BANDS), + ) + shutil.rmtree(str(store_path)) + + root = zarr.open_group(str(store_path), mode="w", zarr_format=3) + h, w = GLOBAL_LEVEL0_H, GLOBAL_LEVEL0_W + band_data = np.arange(GLOBAL_NUM_BANDS, dtype=np.int32) + + for lvl in range(num_levels): + if h < 1 or w < 1: + break + level_dir = os.path.join(str(store_path), str(lvl)) + os.makedirs(level_dir, exist_ok=True) + group_meta = os.path.join(level_dir, "zarr.json") + if not os.path.exists(group_meta): + with open(group_meta, "w") as f: + _json.dump( + {"zarr_format": 3, "node_type": "group", "attributes": {}}, + f, + ) + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + root.create_array( + f"{lvl}/rgb", + shape=(h, w, GLOBAL_NUM_BANDS), + chunks=(GLOBAL_CHUNK, GLOBAL_CHUNK, GLOBAL_NUM_BANDS), + dtype=np.uint8, + fill_value=np.uint8(0), + compressors=BloscCodec(cname="zstd", clevel=3), + dimension_names=["lat", "lon", "band"], + ) + root.create_array( + f"{lvl}/band", + data=band_data, + chunks=(GLOBAL_NUM_BANDS,), + ) + h //= 2 + w //= 2 + + from topozarr.metadata import create_multiscale_metadata + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + actual_levels = len([k for k in root.keys() if k.isdigit()]) + ms_attrs = create_multiscale_metadata(actual_levels, "EPSG:4326", "mean") + ms_attrs["multiscales"]["crs"] = "EPSG:4326" + west, south, east, north = GLOBAL_BOUNDS + ms_attrs["spatial"] = { + "bounds": [west, south, east, north], + "resolution": GLOBAL_BASE_RES, + } + root.attrs.update(ms_attrs) + import warnings + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Consolidated metadata") + zarr.consolidate_metadata(str(store_path)) + + +def _reproject_chunk( + global_arr, + chunk_row: int, + chunk_col: int, + src_arr, + src_epsg: int, + src_pixel: float, + src_origin_e: float, + src_origin_n: float, + src_h: int, + src_w: int, + to_utm, +) -> bool: + """Reproject one 512x512 output chunk from UTM source and write to global_arr. + + Each call writes to a unique (chunk_row, chunk_col) position, so concurrent + calls to different positions are safe. + + Returns True if any non-zero data was written. + """ + import warnings + + from affine import Affine + from rasterio.enums import Resampling + import rasterio.warp + from rasterio.errors import NotGeoreferencedWarning + + warnings.filterwarnings("ignore", category=NotGeoreferencedWarning) + + west, south, east, north = GLOBAL_BOUNDS + row0 = chunk_row * GLOBAL_CHUNK + col0 = chunk_col * GLOBAL_CHUNK + tile_h = min(GLOBAL_CHUNK, GLOBAL_LEVEL0_H - row0) + tile_w = min(GLOBAL_CHUNK, GLOBAL_LEVEL0_W - col0) + if tile_h <= 0 or tile_w <= 0: + return False + + tile_west = west + col0 * GLOBAL_BASE_RES + tile_north = north - row0 * GLOBAL_BASE_RES + tile_east = tile_west + tile_w * GLOBAL_BASE_RES + tile_south = tile_north - tile_h * GLOBAL_BASE_RES + + dst_transform = Affine( + GLOBAL_BASE_RES, 0, tile_west, + 0, -GLOBAL_BASE_RES, tile_north, + ) + + sample_lons = [tile_west, tile_east, tile_west, tile_east, + (tile_west + tile_east) / 2] + sample_lats = [tile_north, tile_north, tile_south, tile_south, + (tile_north + tile_south) / 2] + try: + utm_xs, utm_ys = to_utm.transform(sample_lons, sample_lats) + except Exception: + return False + + if any(not math.isfinite(v) for v in list(utm_xs) + list(utm_ys)): + return False + + pad = 16 + r_min = max(0, int((src_origin_n - max(utm_ys)) / src_pixel) - pad) + r_max = min(src_h, int(math.ceil( + (src_origin_n - min(utm_ys)) / src_pixel + )) + pad) + c_min = max(0, int((min(utm_xs) - src_origin_e) / src_pixel) - pad) + c_max = min(src_w, int(math.ceil( + (max(utm_xs) - src_origin_e) / src_pixel + )) + pad) + + if r_max <= r_min or c_max <= c_min: + return False + + window = np.asarray(src_arr[r_min:r_max, c_min:c_max, :]) + if not window.any(): + return False + + src_data = np.transpose(window.astype(np.float32), (2, 0, 1)) + del window + + win_transform = Affine( + src_pixel, 0, src_origin_e + c_min * src_pixel, + 0, -src_pixel, src_origin_n - r_min * src_pixel, + ) + + dst_data = np.full( + (GLOBAL_NUM_BANDS, tile_h, tile_w), np.nan, dtype=np.float32, + ) + + try: + rasterio.warp.reproject( + source=src_data, + destination=dst_data, + src_transform=win_transform, + src_crs=f"EPSG:{src_epsg}", + dst_transform=dst_transform, + dst_crs="EPSG:4326", + resampling=Resampling.average, + ) + except Exception: + return False + + del src_data + dst_data = np.nan_to_num(dst_data, nan=0.0) + dst_data = np.clip(dst_data, 0, 255).astype(np.uint8) + out = np.transpose(dst_data, (1, 2, 0)) + del dst_data + + if not out.any(): + return False + + global_arr[row0 : row0 + tile_h, col0 : col0 + tile_w, :] = out + return True + + +def _reproject_zone( + store_path: Path, + zone_num: int, + zone_store_path: Path, + zone_epsg: int, + zone_transform: list, + zone_shape: tuple, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> Tuple[int, int, int, int]: + """Reproject one zone's RGB into level 0 of the global store. + + Uses dask.delayed to parallelise chunk-level reprojection tasks. + Each task writes to a unique chunk position, eliminating write races. + + Returns (row_start, row_end, col_start, col_end) in pixel coords, + snapped to chunk boundaries. + """ + import dask + import zarr + from pyproj import Transformer + + src_pixel = zone_transform[0] + src_origin_e = zone_transform[2] + src_origin_n = zone_transform[5] + src_h, src_w = zone_shape[:2] + + west, south, east, north = GLOBAL_BOUNDS + + # Compute zone's WGS84 bounding box + to_4326 = Transformer.from_crs( + f"EPSG:{zone_epsg}", "EPSG:4326", always_xy=True, + ) + corners_utm = [ + (src_origin_e, src_origin_n), + (src_origin_e + src_w * src_pixel, src_origin_n), + (src_origin_e, src_origin_n - src_h * src_pixel), + (src_origin_e + src_w * src_pixel, src_origin_n - src_h * src_pixel), + ] + mid_e = src_origin_e + src_w * src_pixel / 2 + mid_n = src_origin_n - src_h * src_pixel / 2 + corners_utm += [ + (mid_e, src_origin_n), + (mid_e, src_origin_n - src_h * src_pixel), + (src_origin_e, mid_n), + (src_origin_e + src_w * src_pixel, mid_n), + ] + corners_4326 = [to_4326.transform(e, n) for e, n in corners_utm] + lons = [c[0] for c in corners_4326] + lats = [c[1] for c in corners_4326] + + zlon_min, zlon_max = min(lons), max(lons) + zlat_min, zlat_max = min(lats), max(lats) + + # Snap to chunk boundaries (expand outward) + col_start = max(0, (int(math.floor((zlon_min - west) / GLOBAL_BASE_RES)) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + col_end = min(GLOBAL_LEVEL0_W, + ((int(math.ceil((zlon_max - west) / GLOBAL_BASE_RES)) + + GLOBAL_CHUNK - 1) // GLOBAL_CHUNK * GLOBAL_CHUNK)) + row_start = max(0, (int(math.floor((north - zlat_max) / GLOBAL_BASE_RES)) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + row_end = min(GLOBAL_LEVEL0_H, + ((int(math.ceil((north - zlat_min) / GLOBAL_BASE_RES)) + + GLOBAL_CHUNK - 1) // GLOBAL_CHUNK * GLOBAL_CHUNK)) + + if col_end <= col_start or row_end <= row_start: + if console is not None: + console.print(f" [yellow]Zone {zone_num}: no output region[/yellow]") + return (0, 0, 0, 0) + + n_chunk_rows = (row_end - row_start) // GLOBAL_CHUNK + n_chunk_cols = (col_end - col_start) // GLOBAL_CHUNK + chunk_row_start = row_start // GLOBAL_CHUNK + chunk_col_start = col_start // GLOBAL_CHUNK + + if console is not None: + total_chunks = n_chunk_rows * n_chunk_cols + console.print( + f" Zone {zone_num:02d}: rows {row_start}-{row_end}, " + f"cols {col_start}-{col_end} " + f"({n_chunk_rows}x{n_chunk_cols} = {total_chunks} chunks)" + ) + + global_root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + global_arr = global_root["0/rgb"] + zone_store = zarr.open_group(str(zone_store_path), mode="r") + src_arr = zone_store["rgb"] + + to_utm = Transformer.from_crs( + "EPSG:4326", f"EPSG:{zone_epsg}", always_xy=True, + ) + + chunks_written = 0 + chunks_total = n_chunk_rows * n_chunk_cols + + for batch_start in range(0, n_chunk_rows, GLOBAL_BATCH_CHUNK_ROWS): + batch_end = min(batch_start + GLOBAL_BATCH_CHUNK_ROWS, n_chunk_rows) + + tasks = [] + for cr_offset in range(batch_start, batch_end): + cr = chunk_row_start + cr_offset + for cc_offset in range(n_chunk_cols): + cc = chunk_col_start + cc_offset + task = dask.delayed(_reproject_chunk)( + global_arr=global_arr, + chunk_row=cr, + chunk_col=cc, + src_arr=src_arr, + src_epsg=zone_epsg, + src_pixel=src_pixel, + src_origin_e=src_origin_e, + src_origin_n=src_origin_n, + src_h=src_h, + src_w=src_w, + to_utm=to_utm, + ) + tasks.append(task) + + results = dask.compute(*tasks, scheduler="threads", + num_workers=workers) + batch_written = sum(1 for r in results if r) + chunks_written += batch_written + + if console is not None: + done = min((batch_end) * n_chunk_cols, chunks_total) + pct = int(100 * done / chunks_total) + console.print( + f" [{pct:3d}%] {done}/{chunks_total} chunks " + f"({chunks_written} with data)" + ) + + return (row_start, row_end, col_start, col_end) + + +def _coarsen_zone_pyramid( + store_path: Path, + row_start: int, + row_end: int, + col_start: int, + col_end: int, + num_levels: int, + workers: int, + console: Optional["rich.console.Console"] = None, +) -> None: + """Update pyramid levels 1 through num_levels-1 for the affected region. + + Reads from the previous level and writes coarsened data to the current + level, processing in row-strips parallelised with dask.delayed. + """ + import dask + import zarr + + root = zarr.open_group(str(store_path), mode="r+", zarr_format=3) + + prev_row_start, prev_row_end = row_start, row_end + prev_col_start, prev_col_end = col_start, col_end + + for lvl in range(1, num_levels): + prev_arr_path = f"{lvl - 1}/rgb" + cur_arr_path = f"{lvl}/rgb" + + if prev_arr_path not in root or cur_arr_path not in root: + break + + prev_arr = root[prev_arr_path] + cur_arr = root[cur_arr_path] + cur_h, cur_w = cur_arr.shape[:2] + + lr_start = max(0, (prev_row_start // 2) // GLOBAL_CHUNK * GLOBAL_CHUNK) + lr_end = min(cur_h, ((prev_row_end // 2 + GLOBAL_CHUNK - 1) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + lc_start = max(0, (prev_col_start // 2) // GLOBAL_CHUNK * GLOBAL_CHUNK) + lc_end = min(cur_w, ((prev_col_end // 2 + GLOBAL_CHUNK - 1) + // GLOBAL_CHUNK * GLOBAL_CHUNK)) + + if lr_end <= lr_start or lc_end <= lc_start: + break + + if console is not None: + console.print( + f" Level {lvl}: rows {lr_start}-{lr_end}, " + f"cols {lc_start}-{lc_end}" + ) + + strip_h = GLOBAL_CHUNK + + def _coarsen_strip(r0, _prev_arr=prev_arr, _cur_arr=cur_arr, + _lc_start=lc_start, _lc_end=lc_end, + _cur_h=cur_h): + r1 = min(r0 + strip_h, _cur_h) + sr0 = r0 * 2 + sr1 = min(sr0 + (r1 - r0) * 2, _prev_arr.shape[0]) + sc0 = _lc_start * 2 + sc1 = min(sc0 + (_lc_end - _lc_start) * 2, _prev_arr.shape[1]) + strip = np.asarray( + _prev_arr[sr0:sr1, sc0:sc1, :] + ).astype(np.float32) + th = strip.shape[0] // 2 + tw = strip.shape[1] // 2 + if th == 0 or tw == 0: + return + coarsened = ( + strip[: th * 2, : tw * 2, :] + .reshape(th, 2, tw, 2, GLOBAL_NUM_BANDS) + .mean(axis=(1, 3)) + ) + result = np.clip(coarsened, 0, 255).astype(np.uint8) + _cur_arr[r0 : r0 + th, _lc_start : _lc_start + tw, :] = result + + strip_starts = list(range(lr_start, lr_end, strip_h)) + tasks = [dask.delayed(_coarsen_strip)(r0) for r0 in strip_starts] + dask.compute(*tasks, scheduler="threads", num_workers=workers) + + prev_row_start, prev_row_end = lr_start, lr_end + prev_col_start, prev_col_end = lc_start, lc_end + + +def build_global_preview( + zarr_dir: Path, + year: int, + zones: Optional[List[int]] = None, + num_levels: int = GLOBAL_DEFAULT_LEVELS, + workers: int = 4, + console: Optional["rich.console.Console"] = None, +) -> Path: + """Create or update global EPSG:4326 preview store from per-zone UTM stores. + + The output store is always ``{zarr_dir}/global_rgb_{year}.zarr``. + If the store already exists, only the specified zones are re-processed. + """ + import gc + import zarr + + if console is not None: + console.print(f"[bold]Building global preview (year={year})[/bold]") + + # 1. Discover zone stores + pattern = re.compile(rf"^utm(\d{{2}})_{year}\.zarr$") + zone_stores: Dict[int, Path] = {} + + for entry in sorted(zarr_dir.iterdir()): + if not entry.is_dir(): + continue + m = pattern.match(entry.name) + if m is None: + continue + zone_num = int(m.group(1)) + if zones is not None and zone_num not in zones: + continue + zone_stores[zone_num] = entry + + if not zone_stores: + msg = f"No zone stores found in {zarr_dir} for year {year}" + if zones is not None: + msg += f" (zones filter: {zones})" + raise FileNotFoundError(msg) + + if console is not None: + console.print( + f" Found {len(zone_stores)} zone store(s): " + f"{sorted(zone_stores.keys())}" + ) + + # 2. Read zone metadata + zone_infos: Dict[int, dict] = {} + for zone_num, store_path in sorted(zone_stores.items()): + store = zarr.open_group(str(store_path), mode="r") + attrs = dict(store.attrs) + if "rgb" not in store: + if console is not None: + console.print( + f" [yellow]Zone {zone_num}: no rgb array, skipping[/yellow]" + ) + continue + zone_infos[zone_num] = { + "store_path": store_path, + "epsg": int(attrs["crs_epsg"]), + "transform": list(attrs["transform"]), + "shape": store["rgb"].shape, + } + + if not zone_infos: + raise FileNotFoundError("No zone stores with rgb arrays found") + + # 3. Ensure global store exists + output_path = zarr_dir / f"global_rgb_{year}.zarr" + if console is not None: + console.print(f" Output: {output_path}") + _ensure_global_store(output_path, num_levels) + if console is not None: + console.print( + f" Global grid: {GLOBAL_LEVEL0_W}x{GLOBAL_LEVEL0_H} " + f"@ {GLOBAL_BASE_RES} deg, {num_levels} levels" + ) + + # 4. Reproject each zone and update pyramid + for zone_num, zinfo in sorted(zone_infos.items()): + if console is not None: + console.print( + f"\n [bold]Processing zone {zone_num:02d} " + f"(EPSG:{zinfo['epsg']})[/bold]" + ) + row_start, row_end, col_start, col_end = _reproject_zone( + store_path=output_path, + zone_num=zone_num, + zone_store_path=zinfo["store_path"], + zone_epsg=zinfo["epsg"], + zone_transform=zinfo["transform"], + zone_shape=zinfo["shape"], + workers=workers, + console=console, + ) + if row_end <= row_start or col_end <= col_start: + continue + if console is not None: + console.print(f" Building pyramid...") + _coarsen_zone_pyramid( + store_path=output_path, + row_start=row_start, + row_end=row_end, + col_start=col_start, + col_end=col_end, + num_levels=num_levels, + workers=workers, + console=console, + ) + gc.collect() + + # 5. Re-consolidate metadata + import warnings + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Consolidated metadata") + zarr.consolidate_metadata(str(output_path)) + if console is not None: + console.print( + f"\n [bold green]Global store updated: {output_path}[/bold green]" + ) + console.print(f" Zones processed: {sorted(zone_infos.keys())}") + + return output_path diff --git a/pyproject.toml b/pyproject.toml index 4e997e4..2fcd05d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,11 @@ dependencies = [ "xarray", "rioxarray", "zarr", - "dask" + "dask", + "pystac", + "ndpyramid", + "topozarr>=0.0.4", + "xproj>=0.2.1", ] [project.urls] @@ -58,6 +62,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["geotessera*"] +[tool.setuptools.package-data] +"geotessera.viewer" = ["*.html"] + [dependency-groups] dev = [ "pytest>=8.4.1", diff --git a/tests/zarr.t b/tests/zarr.t index 7807fd3..7df612a 100644 --- a/tests/zarr.t +++ b/tests/zarr.t @@ -167,3 +167,57 @@ Verify that the CLI help text mentions zarr as a format option: $ geotessera download --help | grep -i zarr | head -1 *zarr* (glob) + +Test: Global Preview Store Structure +------------------------------------- + +Build a zone store with RGB from the Cambridge tiles: + + $ geotessera-registry zarr-build \ + > "$TESTDIR/cb_tiles_zarr" \ + > --output-dir "$TESTDIR/zarr_global_test" \ + > --year 2024 \ + > --rgb 2>&1 | grep -E '(RGB preview|Zone)' | head -3 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + +Build global preview store from the zone store: + + $ geotessera-registry global-preview \ + > "$TESTDIR/zarr_global_test" \ + > --year 2024 \ + > --levels 3 \ + > --workers 2 2>&1 | grep -E '(Building|Found|Processing|Global store)' | head -4 | sed 's/ *$//' + * (glob) + * (glob) + * (glob) + * (glob) + +Verify global preview store structure has multiscales metadata: + + $ uv run python -c " + > import json + > with open('$TESTDIR/zarr_global_test/global_rgb_2024.zarr/zarr.json') as f: + > meta = json.load(f) + > ms = meta['attributes']['multiscales'] + > print(f'crs: {ms[\"crs\"]}') + > print(f'num_levels: {len(ms[\"layout\"])}') + > print(f'has_consolidated: {\"consolidated_metadata\" in meta}') + > cm = meta['consolidated_metadata']['metadata'] + > has_rgb = any('rgb' in k for k in cm) + > print(f'has_rgb_arrays: {has_rgb}') + > first_arr = [v for k, v in cm.items() if 'rgb' in k][0] + > has_blosc = any(c.get('name') == 'blosc' for c in first_arr.get('codecs', [])) + > print(f'has_blosc: {has_blosc}') + > print(f'dimension_names: {first_arr.get(\"dimension_names\")}') + > sp = meta['attributes']['spatial'] + > print(f'bounds: {sp[\"bounds\"]}') + > " + crs: EPSG:4326 + num_levels: 3 + has_consolidated: True + has_rgb_arrays: True + has_blosc: True + dimension_names: ['lat', 'lon', 'band'] + bounds: [-180.0, -90.0, 180.0, 90.0] diff --git a/uv.lock b/uv.lock index 1039b30..e24f2ef 100644 --- a/uv.lock +++ b/uv.lock @@ -2,14 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -40,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -58,6 +74,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "bokeh" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "jinja2" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tornado", marker = "sys_platform != 'emscripten'" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/7ee0c4dfd0255631b0624ce01be178704f91f763f02a1879368eb109befd/bokeh-3.8.2.tar.gz", hash = "sha256:8e7dcacc21d53905581b54328ad2705954f72f2997f99fc332c1de8da53aa3cc", size = 6529251, upload-time = "2026-01-06T00:20:06.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/a8/877f306720bc114c612579c5af36bcb359026b83d051226945499b306b1a/bokeh-3.8.2-py3-none-any.whl", hash = "sha256:5e2c0d84f75acb25d60efb9e4d2f434a791c4639b47d685534194c4e07bd0111", size = 7207131, upload-time = "2026-01-06T00:20:04.917Z" }, +] + +[[package]] +name = "bottleneck" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/d8/6d641573e210768816023a64966d66463f2ce9fc9945fa03290c8a18f87c/bottleneck-1.6.0.tar.gz", hash = "sha256:028d46ee4b025ad9ab4d79924113816f825f62b17b87c9e1d0d8ce144a4a0e31", size = 104311, upload-time = "2025-09-08T16:30:38.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/96/9d51012d729f97de1e75aad986f3ba50956742a40fc99cbab4c2aa896c1c/bottleneck-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:69ef4514782afe39db2497aaea93b1c167ab7ab3bc5e3930500ef9cf11841db7", size = 100400, upload-time = "2025-09-08T16:29:44.464Z" }, + { url = "https://files.pythonhosted.org/packages/16/f4/4fcbebcbc42376a77e395a6838575950587e5eb82edf47d103f8daa7ba22/bottleneck-1.6.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:727363f99edc6dc83d52ed28224d4cb858c07a01c336c7499c0c2e5dd4fd3e4a", size = 375920, upload-time = "2025-09-08T16:29:45.52Z" }, + { url = "https://files.pythonhosted.org/packages/36/13/7fa8cdc41cbf2dfe0540f98e1e0caf9ffbd681b1a0fc679a91c2698adaf9/bottleneck-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:847671a9e392220d1dfd2ff2524b4d61ec47b2a36ea78e169d2aa357fd9d933a", size = 367922, upload-time = "2025-09-08T16:29:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/13/7d/dccfa4a2792c1bdc0efdde8267e527727e517df1ff0d4976b84e0268c2f9/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:daef2603ab7b4ec4f032bb54facf5fa92dacd3a264c2fd9677c9fc22bcb5a245", size = 361379, upload-time = "2025-09-08T16:29:48.042Z" }, + { url = "https://files.pythonhosted.org/packages/93/42/21c0fad823b71c3a8904cbb847ad45136d25573a2d001a9cff48d3985fab/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fc7f09bda980d967f2e9f1a746eda57479f824f66de0b92b9835c431a8c922d4", size = 371911, upload-time = "2025-09-08T16:29:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b0/830ff80f8c74577d53034c494639eac7a0ffc70935c01ceadfbe77f590c2/bottleneck-1.6.0-cp311-cp311-win32.whl", hash = "sha256:1f78bad13ad190180f73cceb92d22f4101bde3d768f4647030089f704ae7cac7", size = 107831, upload-time = "2025-09-08T16:29:51.397Z" }, + { url = "https://files.pythonhosted.org/packages/6f/42/01d4920b0aa51fba503f112c90714547609bbe17b6ecfc1c7ae1da3183df/bottleneck-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f2adef59fdb9edf2983fe3a4c07e5d1b677c43e5669f4711da2c3daad8321ad", size = 113358, upload-time = "2025-09-08T16:29:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/8d/72/7e3593a2a3dd69ec831a9981a7b1443647acb66a5aec34c1620a5f7f8498/bottleneck-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3bb16a16a86a655fdbb34df672109a8a227bb5f9c9cf5bb8ae400a639bc52fa3", size = 100515, upload-time = "2025-09-08T16:29:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d4/e7bbea08f4c0f0bab819d38c1a613da5f194fba7b19aae3e2b3a27e78886/bottleneck-1.6.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0fbf5d0787af9aee6cef4db9cdd14975ce24bd02e0cc30155a51411ebe2ff35f", size = 377451, upload-time = "2025-09-08T16:29:56.718Z" }, + { url = "https://files.pythonhosted.org/packages/fe/80/a6da430e3b1a12fd85f9fe90d3ad8fe9a527ecb046644c37b4b3f4baacfc/bottleneck-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d08966f4a22384862258940346a72087a6f7cebb19038fbf3a3f6690ee7fd39f", size = 368303, upload-time = "2025-09-08T16:29:57.834Z" }, + { url = "https://files.pythonhosted.org/packages/30/11/abd30a49f3251f4538430e5f876df96f2b39dabf49e05c5836820d2c31fe/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:604f0b898b43b7bc631c564630e936a8759d2d952641c8b02f71e31dbcd9deaa", size = 361232, upload-time = "2025-09-08T16:29:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ac/1c0e09d8d92b9951f675bd42463ce76c3c3657b31c5bf53ca1f6dd9eccff/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d33720bad761e642abc18eda5f188ff2841191c9f63f9d0c052245decc0faeb9", size = 373234, upload-time = "2025-09-08T16:30:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ea/382c572ae3057ba885d484726bb63629d1f63abedf91c6cd23974eb35a9b/bottleneck-1.6.0-cp312-cp312-win32.whl", hash = "sha256:a1e5907ec2714efbe7075d9207b58c22ab6984a59102e4ecd78dced80dab8374", size = 108020, upload-time = "2025-09-08T16:30:01.773Z" }, + { url = "https://files.pythonhosted.org/packages/48/ad/d71da675eef85ac153eef5111ca0caa924548c9591da00939bcabba8de8e/bottleneck-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:81e3822499f057a917b7d3972ebc631ac63c6bbcc79ad3542a66c4c40634e3a6", size = 113493, upload-time = "2025-09-08T16:30:02.872Z" }, + { url = "https://files.pythonhosted.org/packages/97/1a/e117cd5ff7056126d3291deb29ac8066476e60b852555b95beb3fc9d62a0/bottleneck-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015de414ca016ebe56440bdf5d3d1204085080527a3c51f5b7b7a3e704fe6fd", size = 100521, upload-time = "2025-09-08T16:30:03.89Z" }, + { url = "https://files.pythonhosted.org/packages/bd/22/05555a9752357e24caa1cd92324d1a7fdde6386aab162fcc451f8f8eedc2/bottleneck-1.6.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:456757c9525b0b12356f472e38020ed4b76b18375fd76e055f8d33fb62956f5e", size = 377719, upload-time = "2025-09-08T16:30:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/76593af47097d9633109bed04dbcf2170707dd84313ca29f436f9234bc51/bottleneck-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c65254d51b6063c55f6272f175e867e2078342ae75f74be29d6612e9627b2c0", size = 368577, upload-time = "2025-09-08T16:30:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/4dcacaf637d2b8d89ea746c74159adda43858d47358978880614c3fa4391/bottleneck-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a172322895fbb79c6127474f1b0db0866895f0b804a18d5c6b841fea093927fe", size = 361441, upload-time = "2025-09-08T16:30:07.613Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/21eb1eb1c42cb7be2872d0647c292fc75768d14e1f0db66bf907b24b2464/bottleneck-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5e81b642eb0d5a5bf00312598d7ed142d389728b694322a118c26813f3d1fa9", size = 373416, upload-time = "2025-09-08T16:30:08.899Z" }, + { url = "https://files.pythonhosted.org/packages/48/cb/7957ff40367a151139b5f1854616bf92e578f10804d226fbcdecfd73aead/bottleneck-1.6.0-cp313-cp313-win32.whl", hash = "sha256:543d3a89d22880cd322e44caff859af6c0489657bf9897977d1f5d3d3f77299c", size = 108029, upload-time = "2025-09-08T16:30:09.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/a8/735df4156fa5595501d5d96a6ee102f49c13d2ce9e2a287ad51806bc3ba0/bottleneck-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:48a44307d604ceb81e256903e5d57d3adb96a461b1d3c6a69baa2c67e823bd36", size = 113497, upload-time = "2025-09-08T16:30:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5c/8c1260df8ade7cebc2a8af513a27082b5e36aa4a5fb762d56ea6d969d893/bottleneck-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:547e6715115867c4657c9ae8cc5ddac1fec8fdad66690be3a322a7488721b06b", size = 101606, upload-time = "2025-09-08T16:30:11.935Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/f03e2944e91ee962922c834ed21e5be6d067c8395681f5dc6c67a0a26853/bottleneck-1.6.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e4a4a6e05b6f014c307969129e10d1a0afd18f3a2c127b085532a4a76677aef", size = 391804, upload-time = "2025-09-08T16:30:13.13Z" }, + { url = "https://files.pythonhosted.org/packages/0b/58/2b356b8a81eb97637dccee6cf58237198dd828890e38be9afb4e5e58e38e/bottleneck-1.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2baae0d1589b4a520b2f9cf03528c0c8b20717b3f05675e212ec2200cf628f12", size = 383443, upload-time = "2025-09-08T16:30:14.318Z" }, + { url = "https://files.pythonhosted.org/packages/55/52/cf7d09ed3736ad0d50c624787f9b580ae3206494d95cc0f4814b93eef728/bottleneck-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2e407139b322f01d8d5b6b2e8091b810f48a25c7fa5c678cfcdc420dfe8aea0a", size = 375458, upload-time = "2025-09-08T16:30:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e9/7c87a34a24e339860064f20fac49f6738e94f1717bc8726b9c47705601d8/bottleneck-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1adefb89b92aba6de9c6ea871d99bcd29d519f4fb012cc5197917813b4fc2c7f", size = 386384, upload-time = "2025-09-08T16:30:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/59/57/db51855e18a47671801180be748939b4c9422a0544849af1919116346b5f/bottleneck-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:64b8690393494074923780f6abdf5f5577d844b9d9689725d1575a936e74e5f0", size = 109448, upload-time = "2025-09-08T16:30:18.076Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1e/683c090b624f13a5bf88a0be2241dc301e98b2fb72a45812a7ae6e456cc4/bottleneck-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:cb67247f65dcdf62af947c76c6c8b77d9f0ead442cac0edbaa17850d6da4e48d", size = 115190, upload-time = "2025-09-08T16:30:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/eb7c08964a3f3c4719f98795ccd21807ee9dd3071a0f9ad652a5f19196ff/bottleneck-1.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98f1d789042511a0f042b3bdcd2903e8567e956d3aa3be189cce3746daeb8550", size = 100544, upload-time = "2025-09-08T16:30:20.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/ec/c6f3be848f37689f481797ce7d9807d5f69a199d7fc0e46044f9b708c468/bottleneck-1.6.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1fad24c99e39ad7623fc2a76d37feb26bd32e4dd170885edf4dbf4bfce2199a3", size = 378315, upload-time = "2025-09-08T16:30:21.409Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/2d6600836e2ea8f14fcefac592dc83497e5b88d381470c958cb9cdf88706/bottleneck-1.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643e61e50a6f993debc399b495a1609a55b3bd76b057e433e4089505d9f605c7", size = 368978, upload-time = "2025-09-08T16:30:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/bf72b49f5040212873b985feef5050015645e0a02204b591e1d265fc522a/bottleneck-1.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa668efbe4c6b200524ea0ebd537212da9b9801287138016fdf64119d6fcf201", size = 362074, upload-time = "2025-09-08T16:30:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c8/c4891a0604eb680031390182c6e264247e3a9a8d067d654362245396fadf/bottleneck-1.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f7dd35262e89e28fedd79d45022394b1fa1aceb61d2e747c6d6842e50546daa", size = 374019, upload-time = "2025-09-08T16:30:26.438Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2d/ed096f8d1b9147e84914045dd89bc64e3c32eee49b862d1e20d573a9ab0d/bottleneck-1.6.0-cp314-cp314-win32.whl", hash = "sha256:bd90bec3c470b7fdfafc2fbdcd7a1c55a4e57b5cdad88d40eea5bc9bab759bf1", size = 110173, upload-time = "2025-09-08T16:30:27.521Z" }, + { url = "https://files.pythonhosted.org/packages/33/70/1414acb6ae378a15063cfb19a0a39d69d1b6baae1120a64d2b069902549b/bottleneck-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:b43b6d36a62ffdedc6368cf9a708e4d0a30d98656c2b5f33d88894e1bcfd6857", size = 115899, upload-time = "2025-09-08T16:30:28.524Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ed/4570b5d8c1c85ce3c54963ebc37472231ed54f0b0d8dbb5dde14303f775f/bottleneck-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:53296707a8e195b5dcaa804b714bd222b5e446bd93cd496008122277eb43fa87", size = 101615, upload-time = "2025-09-08T16:30:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/2d/93/c148faa07ae91f266be1f3fad1fde95aa2449e12937f3f3df2dd720b86e0/bottleneck-1.6.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6df19cc48a83efd70f6d6874332aa31c3f5ca06a98b782449064abbd564cf0e", size = 392411, upload-time = "2025-09-08T16:30:31.186Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/e6ad221d345a059e7efb2ad1d46a22d9fdae0486faef70555766e1123966/bottleneck-1.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96bb3a52cb3c0aadfedce3106f93ab940a49c9d35cd4ed612e031f6deb27e80f", size = 384022, upload-time = "2025-09-08T16:30:32.364Z" }, + { url = "https://files.pythonhosted.org/packages/4f/40/5b15c01eb8c59d59bc84c94d01d3d30797c961f10ec190f53c27e05d62ab/bottleneck-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1db9e831b69d5595b12e79aeb04cb02873db35576467c8dd26cdc1ee6b74581", size = 376004, upload-time = "2025-09-08T16:30:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/74/f6/cb228f5949553a5c01d1d5a3c933f0216d78540d9e0bf8dd4343bb449681/bottleneck-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4dd7ac619570865fcb7a0e8925df418005f076286ad2c702dd0f447231d7a055", size = 386909, upload-time = "2025-09-08T16:30:34.973Z" }, + { url = "https://files.pythonhosted.org/packages/09/9a/425065c37a67a9120bf53290371579b83d05bf46f3212cce65d8c01d470a/bottleneck-1.6.0-cp314-cp314t-win32.whl", hash = "sha256:7fb694165df95d428fe00b98b9ea7d126ef786c4a4b7d43ae2530248396cadcb", size = 111636, upload-time = "2025-09-08T16:30:36.044Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/c41006e42909ec5114a8961818412310aa54646d1eae0495dbff3598a095/bottleneck-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:174b80930ce82bd8456c67f1abb28a5975c68db49d254783ce2cb6983b4fea40", size = 117611, upload-time = "2025-09-08T16:30:37.055Z" }, +] + +[[package]] +name = "cartopy" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyproj" }, + { name = "pyshp" }, + { name = "shapely" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/ec3dee34237b696a486d566a6d3ae6550ae821836e0412bafdcbbec2cfd2/cartopy-0.25.0.tar.gz", hash = "sha256:55f1a390e5f3f075b221c7d91fb10258ad978db786c7930eba06eb45d28753fe", size = 10767728, upload-time = "2025-08-01T12:44:16.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e1/6a52ee21424da0ed30860f4e94d1657ade8d4436f0718485badf0e63011e/cartopy-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e41d52160548a7ab7774423911db3bfb5a8bc0929580958b1945d3a004da872", size = 11006320, upload-time = "2025-08-01T12:43:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/68/06/38bcfeab9822acffc86474659d33c4dc3c5dec4e61e9927fb8cc8617f651/cartopy-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:432e2a2688fc58af43b9b6bf1d343bb08e2d6ef298efa91e55445f1d308b5ef3", size = 10995635, upload-time = "2025-08-01T12:43:50.855Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b6/f39407d27d641a949496a52ab00220fe0635758e3cb7afb4b7328abe17e7/cartopy-0.25.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:999e44021db07dcf895b115934fb0816aef39985fbaca6ded61d2536355531de", size = 11808214, upload-time = "2025-08-01T12:43:53.218Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c0/b33ac1f586608e80a5e10f3924e16c117da333fcb5e5240839e6681ac3d5/cartopy-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:4139e5ca9faaa037e0576cdcf625b9461a0b404d60e9d20ea24c4d8dbe6f689d", size = 10983301, upload-time = "2025-08-01T12:43:55.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/35/b19901cbe7f1b118dccbb9e655cda7d01a31ee1ecd67e5d2d8afe119f6d3/cartopy-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:060a7b835c0c4222c1067b6ffb2f9c18458abaa35b6624573a3aa37ecf55f4bf", size = 11006900, upload-time = "2025-08-01T12:43:57.708Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/09e824f86be09152ec0f1fa1fe69affbd34eac7a13b545e2e08b9b6bc8ff/cartopy-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57717cb603aecff03ecfee1bc153bb4022c054fcd51a4214a1bb53e5a6f74465", size = 10994813, upload-time = "2025-08-01T12:44:00.069Z" }, + { url = "https://files.pythonhosted.org/packages/b9/30/7465b650110514fc5c9c3b59935264c35ab56f876322de34efa55367ee4e/cartopy-0.25.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c256351433155ef51dde976557212f4e230b8cca4e5d0d9b9a2737ad92959d", size = 11799069, upload-time = "2025-08-01T12:44:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/1d/52/3a57ecb4598c33ee06b512d3686e46b3983e65abd6ec94c5262d01930ed9/cartopy-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:efedb82f38409b72becdfee02231126952816d33a68b1c584bd2136713036bfb", size = 10983127, upload-time = "2025-08-01T12:44:04.441Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b9/0773ff8f1c755b8a362029e6910db87064d27ca021b060c48ce511ec98b7/cartopy-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6fcd2df8039293096f957fc9c76e969b1a9715d12ab8cee1a6bdae0c6773b8b", size = 11007728, upload-time = "2025-08-01T12:44:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/34/a6/75738630b7f64bca7afc6bc5de08ddf0c61f13563f2a1abf642373d1095e/cartopy-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4def451617e6957169447fe6ecdad0f63ef2d2007e7d451dd7b9656ada57382", size = 10996613, upload-time = "2025-08-01T12:44:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/669d4bbeb36b87ba504409d85c68ec297e6f434ea6525424f8aa5f14abac/cartopy-0.25.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c388824cb13e4fa9c2901dc4fbb2dbe9547acd2f4a6a3440983d4e6c6973ae3", size = 11829044, upload-time = "2025-08-01T12:44:11.402Z" }, + { url = "https://files.pythonhosted.org/packages/01/ff/b46e2120abd99b2ff3d376dc91ed58ae8f0a052d57c242c9b140497573dd/cartopy-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:60bad14c072d16e3c96967638cd66eb5a62cf24bc70087bcbfc6b30a3872ed26", size = 10987060, upload-time = "2025-08-01T12:44:14.222Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -67,6 +211,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cf-xarray" +version = "0.10.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/78/f4f38e7ea6221773ea48d85c00d529b1fdc7378a1a1b77c2b77661446a0b/cf_xarray-0.10.11.tar.gz", hash = "sha256:e10ee37b0ed3ba36f42346360f2bc070c690ddc73bb9dcdd9463b3a221453be3", size = 686693, upload-time = "2026-02-03T19:17:42.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ce/5c4f4660da5521d90bea62cdf8396d7e4ce4a00513e218d267b97f9ea453/cf_xarray-0.10.11-py3-none-any.whl", hash = "sha256:c47fff625766c69a66fedef368d9787acb0819b32d8bd022f8b045089b42109a", size = 78421, upload-time = "2026-02-03T19:17:40.431Z" }, +] + +[[package]] +name = "cftime" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f6/9da7aba9548ede62d25936b8b448acd7e53e5dcc710896f66863dcc9a318/cftime-1.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:474e728f5a387299418f8d7cb9c52248dcd5d977b2a01de7ec06bba572e26b02", size = 512733, upload-time = "2025-10-13T18:56:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d5/d86ad95fc1fd89947c34b495ff6487b6d361cf77500217423b4ebcb1f0c2/cftime-1.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9e80d4de815cac2e2d88a2335231254980e545d0196eb34ee8f7ed612645f1", size = 492946, upload-time = "2025-10-13T18:56:01.262Z" }, + { url = "https://files.pythonhosted.org/packages/4f/93/d7e8dd76b03a9d5be41a3b3185feffc7ea5359228bdffe7aa43ac772a75b/cftime-1.6.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad24a563784e4795cb3d04bd985895b5db49ace2cbb71fcf1321fd80141f9a52", size = 1689856, upload-time = "2025-10-13T19:39:12.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8d/86586c0d75110f774e46e2bd6d134e2d1cca1dedc9bb08c388fa3df76acd/cftime-1.6.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3cda6fd12c7fb25eff40a6a857a2bf4d03e8cc71f80485d8ddc65ccbd80f16a", size = 1718573, upload-time = "2025-10-13T18:56:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/7956914cfc135992e89098ebbc67d683c51ace5366ba4b114fef1de89b21/cftime-1.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:28cda78d685397ba23d06273b9c916c3938d8d9e6872a537e76b8408a321369b", size = 1788563, upload-time = "2025-10-13T18:56:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c7/6669708fcfe1bb7b2a7ce693b8cc67165eac00d3ac5a5e8f6ce1be551ff9/cftime-1.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:93ead088e3a216bdeb9368733a0ef89a7451dfc1d2de310c1c0366a56ad60dc8", size = 473631, upload-time = "2025-10-13T18:56:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/82/c5/d70cb1ab533ca790d7c9b69f98215fa4fead17f05547e928c8f2b8f96e54/cftime-1.6.5-cp311-cp311-win_arm64.whl", hash = "sha256:3384d69a0a7f3d45bded21a8cbcce66c8ba06c13498eac26c2de41b1b9b6e890", size = 459383, upload-time = "2026-01-02T21:16:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c1/e8cb7f78a3f87295450e7300ebaecf83076d96a99a76190593d4e1d2be40/cftime-1.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eef25caed5ebd003a38719bd3ff8847cd52ef2ea56c3ebdb2c9345ba131fc7c5", size = 504175, upload-time = "2025-10-13T18:56:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/50/1a/86e1072b09b2f9049bb7378869f64b6747f96a4f3008142afed8955b52a4/cftime-1.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87d2f3b949e45463e559233c69e6a9cf691b2b378c1f7556166adfabbd1c6b0", size = 485980, upload-time = "2025-10-13T18:56:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/d3177b60da3f308b60dee2aef2eb69997acfab1e863f0bf0d2a418396ce5/cftime-1.6.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:82cb413973cc51b55642b3a1ca5b28db5b93a294edbef7dc049c074b478b4647", size = 1591166, upload-time = "2025-10-13T19:39:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fd/a7266970312df65e68b5641b86e0540a739182f5e9c62eec6dbd29f18055/cftime-1.6.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85ba8e7356d239cfe56ef7707ac30feaf67964642ac760a82e507ee3c5db4ac4", size = 1642614, upload-time = "2025-10-13T18:56:09.815Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/f0035a4bc2df8885bb7bd5fe63659686ea1ec7d0cc74b4e3d50e447402e5/cftime-1.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:456039af7907a3146689bb80bfd8edabd074c7f3b4eca61f91b9c2670addd7ad", size = 1688090, upload-time = "2025-10-13T18:56:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/8856a0ab76708553ff597dd2e617b088c734ba87dc3fd395e2b2f3efffe8/cftime-1.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:da84534c43699960dc980a9a765c33433c5de1a719a4916748c2d0e97a071e44", size = 464840, upload-time = "2025-10-13T18:56:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/451009a986d9273d2208fc0898aa00262275b5773259bf3f942f6716a9e7/cftime-1.6.5-cp312-cp312-win_arm64.whl", hash = "sha256:c62cd8db9ea40131eea7d4523691c5d806d3265d31279e4a58574a42c28acd77", size = 450534, upload-time = "2026-01-02T21:16:48.784Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/74ea344b3b003fada346ed98a6899085d6fd4c777df608992d90c458fda6/cftime-1.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4aba66fd6497711a47c656f3a732c2d1755ad15f80e323c44a8716ebde39ddd5", size = 502453, upload-time = "2025-10-13T18:56:13.545Z" }, + { url = "https://files.pythonhosted.org/packages/1e/14/adb293ac6127079b49ff11c05cf3d5ce5c1f17d097f326dc02d74ddfcb6e/cftime-1.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89e7cba699242366e67d6fb5aee579440e791063f92a93853610c91647167c0d", size = 484541, upload-time = "2025-10-13T18:56:14.612Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/bb8a4566af8d0ef3f045d56c462a9115da4f04b07c7fbbf2b4875223eebd/cftime-1.6.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f1eb43d7a7b919ec99aee709fb62ef87ef1cf0679829ef93d37cc1c725781e9", size = 1591014, upload-time = "2025-10-13T19:39:15.346Z" }, + { url = "https://files.pythonhosted.org/packages/ba/08/52f06ff2f04d376f9cd2c211aefcf2b37f1978e43289341f362fc99f6a0e/cftime-1.6.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e02a1d80ffc33fe469c7db68aa24c4a87f01da0c0c621373e5edadc92964900b", size = 1633625, upload-time = "2025-10-13T18:56:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/03e0b23d58ea8fab94ecb4f7c5b721e844a0800c13694876149d98830a73/cftime-1.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18ab754805233cdd889614b2b3b86a642f6d51a57a1ec327c48053f3414f87d8", size = 1684269, upload-time = "2025-10-13T18:56:17.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/60/a0cfba63847b43599ef1cdbbf682e61894994c22b9a79fd9e1e8c7e9de41/cftime-1.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:6c27add8f907f4a4cd400e89438f2ea33e2eb5072541a157a4d013b7dbe93f9c", size = 465364, upload-time = "2025-10-13T18:56:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e8/ec32f2aef22c15604e6fda39ff8d581a00b5469349f8fba61640d5358d2c/cftime-1.6.5-cp313-cp313-win_arm64.whl", hash = "sha256:31d1ff8f6bbd4ca209099d24459ec16dea4fb4c9ab740fbb66dd057ccbd9b1b9", size = 450468, upload-time = "2026-01-02T21:16:50.193Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6c/a9618f589688358e279720f5c0fe67ef0077fba07334ce26895403ebc260/cftime-1.6.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c69ce3bdae6a322cbb44e9ebc20770d47748002fb9d68846a1e934f1bd5daf0b", size = 502725, upload-time = "2025-10-13T18:56:19.424Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e3/da3c36398bfb730b96248d006cabaceed87e401ff56edafb2a978293e228/cftime-1.6.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e62e9f2943e014c5ef583245bf2e878398af131c97e64f8cd47c1d7baef5c4e2", size = 485445, upload-time = "2025-10-13T18:56:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/93/b05939e5abd14bd1ab69538bbe374b4ee2a15467b189ff895e9a8cdaddf6/cftime-1.6.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da5fdaa4360d8cb89b71b8ded9314f2246aa34581e8105c94ad58d6102d9e4f", size = 1584434, upload-time = "2025-10-13T19:39:17.084Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bff865b4ea4304f2744a1ad2b8149b8328b321dd7a2b9746ef926d229bd7cd49", size = 1609812, upload-time = "2025-10-13T18:56:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0f/901b4835aa67ad3e915605d4e01d0af80a44b114eefab74ae33de6d36933/cftime-1.6.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e552c5d1c8a58f25af7521e49237db7ca52ed2953e974fe9f7c4491e95fdd36c", size = 1669768, upload-time = "2025-10-13T18:56:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/e605e4b28363e7a9ae98ed12cabbda5b155b6009270e6a231d8f10182a17/cftime-1.6.5-cp314-cp314-win_amd64.whl", hash = "sha256:e645b095dc50a38ac454b7e7f0742f639e7d7f6b108ad329358544a6ff8c9ba2", size = 463818, upload-time = "2025-10-13T18:56:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/a8f85ae697ff10206ec401c2621f5ca9f327554f586d62f244739ceeb347/cftime-1.6.5-cp314-cp314-win_arm64.whl", hash = "sha256:b9044d7ac82d3d8af189df1032fdc871bbd3f3dd41a6ec79edceb5029b71e6e0", size = 459862, upload-time = "2026-01-02T20:45:02.625Z" }, + { url = "https://files.pythonhosted.org/packages/ab/05/7410e12fd03a0c52717e74e6a1b49958810807dda212e23b65d43ea99676/cftime-1.6.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ef56460cb0576e1a9161e1428c9e1a633f809a23fa9d598f313748c1ae5064e", size = 533781, upload-time = "2026-01-02T20:45:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/44/ba/10e3546426d3ed9f9cc82e4a99836bb6fac1642c7830f7bdd0ac1c3f0805/cftime-1.6.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f4873d38b10032f9f3111c547a1d485519ae64eee6a7a2d091f1f8b08e1ba50", size = 515218, upload-time = "2026-01-02T20:45:06.788Z" }, + { url = "https://files.pythonhosted.org/packages/bd/68/efa11eae867749e921bfec6a865afdba8166e96188112dde70bb8bb49254/cftime-1.6.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccce0f4c9d3f38dd948a117e578b50d0e0db11e2ca9435fb358fd524813e4b61", size = 1579932, upload-time = "2026-01-02T20:45:11.194Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6c/0971e602c1390a423e6621dfbad9f1d375186bdaf9c9c7f75e06f1fbf355/cftime-1.6.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19cbfc5152fb0b34ce03acf9668229af388d7baa63a78f936239cb011ccbe6b1", size = 1555894, upload-time = "2026-01-02T20:45:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fc/8475a15b7c3209a4a68b563dfc5e01ce74f2d8b9822372c3d30c68ab7f39/cftime-1.6.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4470cd5ef3c2514566f53efbcbb64dd924fa0584637d90285b2f983bd4ee7d97", size = 513027, upload-time = "2026-01-02T20:45:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -313,6 +514,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/23/d39ccc4ed76222db31530b0a7d38876fdb7673e23f838e8d8f0ed4651a4f/dask-2026.1.2-py3-none-any.whl", hash = "sha256:46a0cf3b8d87f78a3d2e6b145aea4418a6d6d606fe6a16c79bd8ca2bb862bc91", size = 1482084, upload-time = "2026-01-30T21:04:18.363Z" }, ] +[package.optional-dependencies] +complete = [ + { name = "bokeh" }, + { name = "distributed" }, + { name = "jinja2" }, + { name = "lz4" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyarrow" }, +] + +[[package]] +name = "distributed" +version = "2026.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "dask" }, + { name = "jinja2" }, + { name = "locket" }, + { name = "msgpack" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "sortedcontainers" }, + { name = "tblib" }, + { name = "toolz" }, + { name = "tornado" }, + { name = "urllib3" }, + { name = "zict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/75/b6e5b77229097ff03dd5ba6a07c77e2da87e7e991ccfef412549bba78746/distributed-2026.1.2.tar.gz", hash = "sha256:8333fa7a34151ed3b4cf1a03136fe1f1799eca706a5e47bdb63022c8795d853b", size = 2103721, upload-time = "2026-01-30T21:07:03.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/14/0fe5889a83991ac29c93e6b2e121ad2afc3bff5f9327f34447d3068d8142/distributed-2026.1.2-py3-none-any.whl", hash = "sha256:30ccb5587351f50304f6f6e219ea91bc09d88401125779caa8be5253e9d3ecf2", size = 1009083, upload-time = "2026-01-30T21:07:01.363Z" }, +] + [[package]] name = "docutils" version = "0.22.4" @@ -334,6 +572,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, ] +[[package]] +name = "flox" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "numpy-groupies" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scipy" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/fd/94b6e6dba647c062434f21b40efe800ca3fed2292856f82c816a8a626b97/flox-0.11.2.tar.gz", hash = "sha256:ca48d391e4a06cdf1c24368bf73114191851149611a5b318d82436fa713efdf0", size = 956626, upload-time = "2026-02-26T05:41:53.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/7b/4ce04d96cb4538ab3eab6bbcc06e624bbbc3661011bfab0b536f4fb026ab/flox-0.11.2-py3-none-any.whl", hash = "sha256:d3932bfe9e26729dd7e471c4d448abeaee7a62272621f584de0e704b3c0665cb", size = 89143, upload-time = "2026-02-26T05:41:54.725Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -431,9 +686,11 @@ dependencies = [ { name = "geodatasets" }, { name = "geopandas" }, { name = "matplotlib" }, + { name = "ndpyramid" }, { name = "numpy" }, { name = "pandas" }, { name = "pyarrow" }, + { name = "pystac" }, { name = "rasterio", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "rasterio", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "rich" }, @@ -443,8 +700,10 @@ dependencies = [ { name = "scikit-learn" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "topozarr" }, { name = "typer" }, { name = "xarray" }, + { name = "xproj" }, { name = "zarr" }, ] @@ -461,17 +720,21 @@ requires-dist = [ { name = "geodatasets", specifier = ">=2024.8.0" }, { name = "geopandas" }, { name = "matplotlib" }, + { name = "ndpyramid" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pandas" }, { name = "pyarrow", specifier = ">=17.0.0" }, + { name = "pystac" }, { name = "rasterio" }, { name = "rich" }, { name = "rioxarray" }, { name = "scikit-image", specifier = ">=0.25.2" }, { name = "scikit-learn", specifier = ">=1.7.1" }, { name = "sphinx", specifier = ">=8.2.3" }, + { name = "topozarr", specifier = ">=0.0.4" }, { name = "typer" }, { name = "xarray" }, + { name = "xproj", specifier = ">=0.2.1" }, { name = "zarr" }, ] @@ -511,6 +774,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] +[[package]] +name = "h5netcdf" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/03/92d6cc02c0055158167255980461155d6e17f1c4143c03f8bcc18d3e3f3a/h5netcdf-1.8.1.tar.gz", hash = "sha256:9b396a4cc346050fc1a4df8523bc1853681ec3544e0449027ae397cb953c7a16", size = 78679, upload-time = "2026-01-23T07:35:31.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/8b/88f16936a8e8070a83d36239555227ecd91728f9ef222c5382cda07e0fd6/h5netcdf-1.8.1-py3-none-any.whl", hash = "sha256:a76ed7cfc9b8a8908ea7057c4e57e27307acff1049b7f5ed52db6c2247636879", size = 62915, upload-time = "2026-01-23T07:35:30.195Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -547,7 +823,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -686,6 +962,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, ] +[[package]] +name = "legacy-cgi" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/a1/2ad4b2367915faeebe8447f0a057861f646dbf5fbbb3561db42c65659cf3/llvmlite-0.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82f3d39b16f19aa1a56d5fe625883a6ab600d5cc9ea8906cca70ce94cabba067", size = 37232766, upload-time = "2025-12-08T18:14:48.836Z" }, + { url = "https://files.pythonhosted.org/packages/12/b5/99cf8772fdd846c07da4fd70f07812a3c8fd17ea2409522c946bb0f2b277/llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3df43900119803bbc52720e758c76f316a9a0f34612a886862dfe0a5591a17e", size = 56275175, upload-time = "2025-12-08T18:14:51.604Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/ed806f9c003563732da156139c45d970ee435bd0bfa5ed8de87ba972b452/llvmlite-0.46.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de183fefc8022d21b0aa37fc3e90410bc3524aed8617f0ff76732fc6c3af5361", size = 55128630, upload-time = "2025-12-08T18:14:55.107Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/8f5a37a65fc9b7b17408508145edd5f86263ad69c19d3574e818f533a0eb/llvmlite-0.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8b10bc585c58bdffec9e0c309bb7d51be1f2f15e169a4b4d42f2389e431eb93", size = 38138652, upload-time = "2025-12-08T18:14:58.171Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z" }, + { url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/e6/91/14f32e1d70905c1c0aa4e6609ab5d705c3183116ca02ac6df2091868413a/llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12", size = 55128629, upload-time = "2025-12-08T18:15:19.493Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a7/d526ae86708cea531935ae777b6dbcabe7db52718e6401e0fb9c5edea80e/llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35", size = 38138941, upload-time = "2025-12-08T18:15:22.536Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, +] + [[package]] name = "locket" version = "1.0.0" @@ -695,6 +1004,156 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -854,6 +1313,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "narwhals" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" }, +] + +[[package]] +name = "nc-time-axis" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cftime" }, + { name = "matplotlib" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/c7/ceaba2047ef4e08660a5a89a71cea30547bddb0e51236dab2dcb771a6fe1/nc-time-axis-1.4.1.tar.gz", hash = "sha256:72d80f492f34bbf59490838d8cb3d92f14e88900b0ee35498b2b33c82795eb81", size = 66231, upload-time = "2022-04-20T11:29:01.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/89/dbeab77a217f8fbda97a637acf1e3f0ce8c9c9fb3f5e5d1ff843da859520/nc_time_axis-1.4.1-py3-none-any.whl", hash = "sha256:96a6fb28cede0d07998fcd666599f76e51a086e1929fbcbfb758c1d0f3e7b0d1", size = 17757, upload-time = "2022-04-20T11:29:00Z" }, +] + +[[package]] +name = "ndpyramid" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cf-xarray" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pyproj" }, + { name = "rasterio", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "rasterio", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "xarray", extra = ["complete"] }, + { name = "zarr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/d2/ad4a6308cd2412263bb935b0628da9218d37b64c2a11e8ae7d6d36ea640b/ndpyramid-0.4.0.tar.gz", hash = "sha256:674b004706e69c6f459035617e361fa11c9fdd90dd0f28bc8c3fb92a93ae20d1", size = 1181121, upload-time = "2024-11-22T23:17:30.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/56/39f30314dc39fe3637ac5f092785cef0bb613da6d5d268b85059eadce0b2/ndpyramid-0.4.0-py3-none-any.whl", hash = "sha256:85236921f5af8b6e4d78b8583d05a8a92b4aa2066edd228fc5e13bc4da0b830a", size = 17400, upload-time = "2024-11-22T23:17:28.885Z" }, +] + +[[package]] +name = "netcdf4" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cftime" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/1e83534ba68459bc5ae39df46fa71003984df58aabf31f7dcd6e22ecddb0/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56688c03444fffe0d0c7512cb45245e650389cd841c955b30e4552fa681c4cd9", size = 10519821, upload-time = "2026-01-05T02:27:15.413Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/a15d6fe97f81d6d5202b17838a9a298b5955b3e9971e20609195112829b5/netcdf4-1.7.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ecf471ba8a6ddb2200121949bedfa0095db228822f38227d5da680694a38358", size = 10371133, upload-time = "2026-01-05T02:27:17.224Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2b/684b15dd4791f8be295b2f6fa97377bbc07a768478a63b7d3c4951712e36/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5841de0735e8e4875b367c668e81d334287858d64dd9f3e3e2261e808c84922", size = 10395635, upload-time = "2026-01-05T02:27:19.655Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/44d21524cf1b1c64254f92e22395a7a10f70c18f3a13a18ac9db258760f7/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fac03a8c5b250d57866e7d98918a64742e4b0de1681c5c86bac5726bab8aee", size = 10237725, upload-time = "2026-01-05T02:27:22.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9d/c3ddf54296ad8f18f02f77f23452bdb0971aece1b87e84bab9d734bf72cc/netcdf4-1.7.4-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ad083d260301b5add74b1669c75ab0df03bdf986decfcc092cb45eec2615b5f1", size = 23515258, upload-time = "2026-01-05T02:27:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/bc0346e995d436d03fab682b7fbd2a9adcf0db6a05790b8f24853bf08170/netcdf4-1.7.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f22014092cc9da3f056b0368e2e38c42afd5725c87ad4843eb2f467e16dd4f6", size = 22910171, upload-time = "2026-01-05T02:27:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/6b/f9bc3f43c55e2dac72ee9f98d77860789bdd5d50c29adf164a6bdb303078/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224a15434c165a5e0225e5831f591edf62533044b1ce62fdfee815195bbd077d", size = 10567579, upload-time = "2026-01-05T02:27:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/e7685c66b7f011c73cd746127f986358a26c642a4e4a1aa5ab51481b6586/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31a2318305de6831a18df25ad0df9f03b6d68666af0356d4f6057d66c02ffeb6", size = 10255032, upload-time = "2026-01-05T02:27:31.744Z" }, + { url = "https://files.pythonhosted.org/packages/a6/14/7506738bb6c8bc373b01e5af8f3b727f83f4f496c6b108490ea2609dc2cf/netcdf4-1.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:6c4a0aa9446c3a616ef3be015b629dc6173643f8b09546de26a4e40e272cd1ed", size = 22289653, upload-time = "2026-01-05T02:27:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -863,6 +1446,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "numba" +version = "0.64.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/c9/a0fb41787d01d621046138da30f6c2100d80857bf34b3390dd68040f27a3/numba-0.64.0.tar.gz", hash = "sha256:95e7300af648baa3308127b1955b52ce6d11889d16e8cfe637b4f85d2fca52b1", size = 2765679, upload-time = "2026-02-18T18:41:20.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/a3/1a4286a1c16136c8896d8e2090d950e79b3ec626d3a8dc9620f6234d5a38/numba-0.64.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:766156ee4b8afeeb2b2e23c81307c5d19031f18d5ce76ae2c5fb1429e72fa92b", size = 2682938, upload-time = "2026-02-18T18:40:52.897Z" }, + { url = "https://files.pythonhosted.org/packages/19/16/aa6e3ba3cd45435c117d1101b278b646444ed05b7c712af631b91353f573/numba-0.64.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d17071b4ffc9d39b75d8e6c101a36f0c81b646123859898c9799cb31807c8f78", size = 3747376, upload-time = "2026-02-18T18:40:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f1/dd2f25e18d75fdf897f730b78c5a7b00cc4450f2405564dbebfaf359f21f/numba-0.64.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ead5630434133bac87fa67526eacb264535e4e9a2d5ec780e0b4fc381a7d275", size = 3453292, upload-time = "2026-02-18T18:40:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/29/e09d5630578a50a2b3fa154990b6b839cf95327aa0709e2d50d0b6816cd1/numba-0.64.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2b1fd93e7aaac07d6fbaed059c00679f591f2423885c206d8c1b55d65ca3f2d", size = 2749824, upload-time = "2026-02-18T18:40:58.392Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/9fc52cb4f0d5e6d8b5f4d81615bc01012e3cf24e1052a60f17a68deb8092/numba-0.64.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69440a8e8bc1a81028446f06b363e28635aa67bd51b1e498023f03b812e0ce68", size = 2683418, upload-time = "2026-02-18T18:40:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/1a74ea99b180b7a5587b0301ed1b183a2937c4b4b67f7994689b5d36fc34/numba-0.64.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13721011f693ba558b8dd4e4db7f2640462bba1b855bdc804be45bbeb55031a", size = 3804087, upload-time = "2026-02-18T18:41:01.699Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/583c647404b15f807410510fec1eb9b80cb8474165940b7749f026f21cbc/numba-0.64.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0b180b1133f2b5d8b3f09d96b6d7a9e51a7da5dda3c09e998b5bcfac85d222c", size = 3504309, upload-time = "2026-02-18T18:41:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/0fce5789b8a5035e7ace21216a468143f3144e02013252116616c58339aa/numba-0.64.0-cp312-cp312-win_amd64.whl", hash = "sha256:e63dc94023b47894849b8b106db28ccb98b49d5498b98878fac1a38f83ac007a", size = 2752740, upload-time = "2026-02-18T18:41:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/2734de90f9300a6e2503b35ee50d9599926b90cbb7ac54f9e40074cd07f1/numba-0.64.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3bab2c872194dcd985f1153b70782ec0fbbe348fffef340264eacd3a76d59fd6", size = 2683392, upload-time = "2026-02-18T18:41:06.563Z" }, + { url = "https://files.pythonhosted.org/packages/42/e8/14b5853ebefd5b37723ef365c5318a30ce0702d39057eaa8d7d76392859d/numba-0.64.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:703a246c60832cad231d2e73c1182f25bf3cc8b699759ec8fe58a2dbc689a70c", size = 3812245, upload-time = "2026-02-18T18:41:07.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/f60dc6c96d19b7185144265a5fbf01c14993d37ff4cd324b09d0212aa7ce/numba-0.64.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2e49a7900ee971d32af7609adc0cfe6aa7477c6f6cccdf6d8138538cf7756f", size = 3511328, upload-time = "2026-02-18T18:41:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/fe7003ea7e7237ee7014f8eaeeb7b0d228a2db22572ca85bab2648cf52cb/numba-0.64.0-cp313-cp313-win_amd64.whl", hash = "sha256:396f43c3f77e78d7ec84cdfc6b04969c78f8f169351b3c4db814b97e7acf4245", size = 2752668, upload-time = "2026-02-18T18:41:11.455Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/77d26afe0988c592dd97cb8d4e80bfb3dfc7dbdacfca7d74a7c5c81dd8c2/numba-0.64.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f565d55eaeff382cbc86c63c8c610347453af3d1e7afb2b6569aac1c9b5c93ce", size = 2683590, upload-time = "2026-02-18T18:41:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4b/600b8b7cdbc7f9cebee9ea3d13bb70052a79baf28944024ffcb59f0712e3/numba-0.64.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9b55169b18892c783f85e9ad9e6f5297a6d12967e4414e6b71361086025ff0bb", size = 3781163, upload-time = "2026-02-18T18:41:15.377Z" }, + { url = "https://files.pythonhosted.org/packages/ff/73/53f2d32bfa45b7175e9944f6b816d8c32840178c3eee9325033db5bf838e/numba-0.64.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:196bcafa02c9dd1707e068434f6d5cedde0feb787e3432f7f1f0e993cc336c4c", size = 3481172, upload-time = "2026-02-18T18:41:17.281Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/aebd2f7f1e11e38814bb96e95a27580817a7b340608d3ac085fdbab83174/numba-0.64.0-cp314-cp314-win_amd64.whl", hash = "sha256:213e9acbe7f1c05090592e79020315c1749dd52517b90e94c517dca3f014d4a1", size = 2754700, upload-time = "2026-02-18T18:41:19.277Z" }, +] + +[[package]] +name = "numbagg" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/5c/0b84cf3e2dc71fc9061bdf9db9c8d0e8ea0eb079b8c98f526831d0d2c1c1/numbagg-0.9.4.tar.gz", hash = "sha256:4aaddc7ca306c38e991f8b812d129a81f31cb77a066282d1e0b18cea5c126250", size = 141712, upload-time = "2025-12-15T06:07:48.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/d3/ff8f1b9968aa4dcd1da1880322ed492314cc920998182e549b586c895a17/numbagg-0.9.4-py3-none-any.whl", hash = "sha256:4f48276333fae856810873122d77aa75fb2d33f9ffe7888c1cfc86209117408f", size = 77532, upload-time = "2025-12-15T06:07:47.099Z" }, +] + [[package]] name = "numcodecs" version = "0.16.5" @@ -974,6 +1598,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] +[[package]] +name = "numpy-groupies" +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ff/0559b586423d9a59feac52c2261501106dcd61e45214862de5fbb03b78cb/numpy_groupies-0.11.3.tar.gz", hash = "sha256:aed4afdad55e856b9e737fe4b4673c77e47c2f887c3663a18baaa200407c23e0", size = 159159, upload-time = "2025-05-22T11:47:58.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/e0/760e73c111193db5ca37712a148e4807d1b0c60302ab31e4ada6528ca34d/numpy_groupies-0.11.3-py3-none-any.whl", hash = "sha256:d4065dd5d56fda941ad5a7c80a7f80b49f671ed148aaa3e243a0e4caa71adcb3", size = 40784, upload-time = "2025-05-22T11:47:56.997Z" }, +] + +[[package]] +name = "opt-einsum" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004, upload-time = "2024-09-26T14:33:24.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -1175,6 +1820,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pyarrow" version = "23.0.0" @@ -1225,6 +1898,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/9c/47693463894b610f8439b2e970b82ef81e9599c757bf2049365e40ff963c/pyarrow-23.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:427deac1f535830a744a4f04a6ac183a64fcac4341b3f618e693c41b7b98d2b0", size = 28338905, upload-time = "2026-01-18T16:19:32.93Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydap" +version = "3.5.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "lxml" }, + { name = "numpy" }, + { name = "requests" }, + { name = "requests-cache" }, + { name = "scipy" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/aa/8dd622677cb0436e0b84993f0bda2331612bab88995691653af9ddc889ee/pydap-3.5.8.tar.gz", hash = "sha256:0dc3c7f28fd456e17ed1c789ccfd119938a2bd1d73828cdf5319c69a213df560", size = 12126573, upload-time = "2025-10-03T19:19:55.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/20/e989ab34f456f2f86a8d1e9013d09cc022e7508868e452084f49210b912e/pydap-3.5.8-py3-none-any.whl", hash = "sha256:7e18b224e8b93d53b9505dc8de34bcb9b1d121169403a836461d758dc998ca5c", size = 2429178, upload-time = "2025-10-03T19:19:53.38Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1357,6 +2160,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] +[[package]] +name = "pyshp" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/20/8b07bae73aaa0c3f5a2683ba6e23b46e977e2d33a88126d56bbcc2d135cd/pyshp-3.0.3.tar.gz", hash = "sha256:bf4678b13dd53578ed87669676a2fffeccbcded1ec8ff9cafb36d1b660f4b305", size = 2192568, upload-time = "2025-11-28T17:47:31.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" }, +] + +[[package]] +name = "pystac" +version = "1.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/e6/efbc20dbc94ad7ed18fe11a4208103a509384ffcccd9bdc27953b725e686/pystac-1.14.3.tar.gz", hash = "sha256:24f92d6f301371859aa0abc1bbe7b1523a603e1184a6d139ecb323967c2c9bb3", size = 164205, upload-time = "2026-01-09T12:38:42.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/b4/a9430e72bfc3c458e1fcf8363890994e483052ab052ed93912be4e5b32c8/pystac-1.14.3-py3-none-any.whl", hash = "sha256:2f60005f521d541fb801428307098f223c14697b3faf4d2f0209afb6a43f39e5", size = 208506, upload-time = "2026-01-09T12:38:40.721Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1445,7 +2269,8 @@ name = "rasterio" version = "1.4.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -1504,12 +2329,18 @@ name = "rasterio" version = "1.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "affine", marker = "python_full_version >= '3.12'" }, @@ -1569,6 +2400,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-cache" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "url-normalize" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/6c/deaf1a9462ce8b6a9ac0ee3603d9ba32917be8e48c8f6799770d5418c3cb/requests_cache-1.3.0.tar.gz", hash = "sha256:070e357ccef11a300ccef4294a85de1ab265833c5d9c9538b26cd7ba4085d54a", size = 97720, upload-time = "2026-02-02T23:17:33.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/3f/dfa42bb16be96d53351aa151cb1e39fcaafe6cda01389c530a2ec809ef8a/requests_cache-1.3.0-py3-none-any.whl", hash = "sha256:f09f27bbf100c250886acf13a9db35b53cf2852fddd71977b47c71ea7d90dbba", size = 69626, upload-time = "2026-02-02T23:17:31.718Z" }, +] + [[package]] name = "rich" version = "14.3.2" @@ -1587,7 +2435,8 @@ name = "rioxarray" version = "0.19.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -1608,12 +2457,18 @@ name = "rioxarray" version = "0.21.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.12'" }, @@ -1848,6 +2703,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, ] +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + [[package]] name = "shapely" version = "2.1.2" @@ -1934,12 +2803,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sparse" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/64/46da3957f8f9af03179eca946d786c56f22b9458cedf472850e02948a8c1/sparse-0.18.0.tar.gz", hash = "sha256:57f92661eb0ec0c764b450c72f3c0d869ea7f32e5e4ca0a335f9d6a7d79bbff4", size = 791987, upload-time = "2026-02-19T06:17:55.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a9/804ac7423f5dda316d3a982f3ab071c971fb877f8961dad9f6a97d12d2ee/sparse-0.18.0-py2.py3-none-any.whl", hash = "sha256:6f4a127d5aae88eca41ea8106bbd5a7830f83d068b37291df597a43511212069", size = 151931, upload-time = "2026-02-19T06:17:53.11Z" }, +] + [[package]] name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.12' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -1972,12 +2873,18 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.12'" }, @@ -2057,6 +2964,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tblib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8a/14c15ae154895cc131174f858c707790d416c444fc69f93918adfd8c4c0b/tblib-3.2.2.tar.gz", hash = "sha256:e9a652692d91bf4f743d4a15bc174c0b76afc750fe8c7b6d195cc1c1d6d2ccec", size = 35046, upload-time = "2025-11-12T12:21:16.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl", hash = "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", size = 12893, upload-time = "2025-11-12T12:21:14.407Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -2087,6 +3003,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, ] +[[package]] +name = "topozarr" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dask" }, + { name = "xarray" }, + { name = "xproj" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/1d/2857a9bafba94cefd9e618e32b5930555b931a1b5a41f1aa2c5b054abe7e/topozarr-0.0.4.tar.gz", hash = "sha256:05015b4a00ec38af61dade9437d8a8bcb4550e08de00cbee02b3dcbeb05c121b", size = 3886, upload-time = "2026-01-22T21:13:55.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/89/7932939d5bbe0bb3baa7ab7aef99039d3935f067b35a1fd6744ef963acd5/topozarr-0.0.4-py3-none-any.whl", hash = "sha256:d7c70f993e4728dd857e57d2b17f8929a357cdd7d55355d115b359774e561f57", size = 5608, upload-time = "2026-01-22T21:13:54.269Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + [[package]] name = "typer" version = "0.23.1" @@ -2111,6 +3060,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" @@ -2120,6 +3081,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "url-normalize" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/31/febb777441e5fcdaacb4522316bf2a527c44551430a4873b052d545e3279/url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37", size = 18846, upload-time = "2025-04-26T20:37:58.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/d9/5ec15501b675f7bc07c5d16aa70d8d778b12375686b6efd47656efdc67cd/url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b", size = 14728, upload-time = "2025-04-26T20:37:57.217Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2129,6 +3102,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "webob" +version = "1.8.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/0b/1732085540b01f65e4e7999e15864fe14cd18b12a95731a43fd6fd11b26a/webob-1.8.9.tar.gz", hash = "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589", size = 279775, upload-time = "2024-10-24T03:19:20.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", size = 115364, upload-time = "2024-10-24T03:19:18.642Z" }, +] + [[package]] name = "xarray" version = "2025.11.0" @@ -2143,6 +3128,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/b4/cfa7aa56807dd2d9db0576c3440b3acd51bae6207338ec5610d4878e5c9b/xarray-2025.11.0-py3-none-any.whl", hash = "sha256:986893b995de4a948429356a3897d78e634243c1cac242bd59d03832b9d72dd1", size = 1375447, upload-time = "2025-11-17T16:12:07.123Z" }, ] +[package.optional-dependencies] +complete = [ + { name = "bottleneck" }, + { name = "cartopy" }, + { name = "cftime" }, + { name = "dask", extra = ["complete"] }, + { name = "flox" }, + { name = "fsspec" }, + { name = "h5netcdf" }, + { name = "matplotlib" }, + { name = "nc-time-axis" }, + { name = "netcdf4" }, + { name = "numba" }, + { name = "numbagg" }, + { name = "opt-einsum" }, + { name = "pooch" }, + { name = "pydap" }, + { name = "scipy" }, + { name = "seaborn" }, + { name = "sparse" }, + { name = "zarr" }, +] + +[[package]] +name = "xproj" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyproj" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/33/8c2bcceedfa13122916b6a582eedf9581682dbee329d95bcfb4c69542ade/xproj-0.2.1.tar.gz", hash = "sha256:d520c7a0df402f6a5922108ed614c8ea0ce6b04bec12c91aceb75fe3c1d3c5c3", size = 31781, upload-time = "2025-07-07T09:05:48.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/b9/b6a9cf72aef69c3e6db869dcc130e19452a658366dac9377f9cd32a76b80/xproj-0.2.1-py3-none-any.whl", hash = "sha256:d762c540c1bc4abc5955b4034b409b9b2f7733c3e49285f3aeb71f9a2dd6bf34", size = 23837, upload-time = "2025-07-07T09:05:46.889Z" }, +] + +[[package]] +name = "xyzservices" +version = "2025.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, +] + [[package]] name = "zarr" version = "3.1.5" @@ -2160,6 +3190,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/15/bb13b4913ef95ad5448490821eee4671d0e67673342e4d4070854e5fe081/zarr-3.1.5-py3-none-any.whl", hash = "sha256:29cd905afb6235b94c09decda4258c888fcb79bb6c862ef7c0b8fe009b5c8563", size = 284067, upload-time = "2025-11-21T14:05:59.235Z" }, ] +[[package]] +name = "zict" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/ac/3c494dd7ec5122cff8252c1a209b282c0867af029f805ae9befd73ae37eb/zict-3.0.0.tar.gz", hash = "sha256:e321e263b6a97aafc0790c3cfb3c04656b7066e6738c37fffcca95d803c9fba5", size = 33238, upload-time = "2023-04-17T21:41:16.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/ab/11a76c1e2126084fde2639514f24e6111b789b0bfa4fc6264a8975c7e1f1/zict-3.0.0-py2.py3-none-any.whl", hash = "sha256:5796e36bd0e0cc8cf0fbc1ace6a68912611c1dbd74750a3f3026b9b9d6a327ae", size = 43332, upload-time = "2023-04-17T21:41:13.444Z" }, +] + [[package]] name = "zipp" version = "3.23.0" diff --git a/viewer/index.html b/viewer/index.html new file mode 100644 index 0000000..3cc802c --- /dev/null +++ b/viewer/index.html @@ -0,0 +1,1611 @@ + + + + + + Geotessera Viewer + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+

Geotessera

+
+

ZARR EMBEDDING VIEWER

+
+ + +
+ +
+ + +
+ +
+ + Manual URL + + +
+
Ready
+
+ + +
+ +
+
+ R + + 0 +
+
+ G + + 1 +
+
+ B + + 2 +
+
+
+ + +
+
+ + 0.80 +
+
+ +
+
+ + + + + +
+ +
+ + +
+
+ + + +
+ + +
+ -- +
+ + + + + + + + + + + + + diff --git a/viewer/serve.py b/viewer/serve.py new file mode 100644 index 0000000..527c3fb --- /dev/null +++ b/viewer/serve.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Convenience script - use 'geotessera-registry serve' instead. + +Usage: + geotessera-registry serve /path/to/zarr/stores + # or with uvx: + uvx geotessera geotessera-registry serve /path/to/zarr/stores +""" +print("Use 'geotessera-registry serve ' instead.") +print("Example: geotessera-registry serve /data/zarr --port 8000")