tilearray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tilearray/array.py ADDED
@@ -0,0 +1,899 @@
1
+ # pyright: reportMissingImports=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false
2
+
3
+ """High-level array construction utilities built on service abstractions."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import os
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+ from typing import (
14
+ TYPE_CHECKING,
15
+ Any,
16
+ Callable,
17
+ cast,
18
+ )
19
+
20
+ import numpy as np
21
+ import xarray as xr
22
+ from dask.array import block as da_block
23
+ from dask.array import concatenate as da_concatenate
24
+ from dask.array import from_delayed as da_from_delayed
25
+ from dask.delayed import Delayed, delayed
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+ from .decode import (
29
+ NDArrayFloat,
30
+ TileDecoder,
31
+ band_count_from_array,
32
+ default_band_count_for_format,
33
+ sniff_effective_content_type,
34
+ )
35
+ from .decode import (
36
+ decoder_for_format as _decoder_for_format,
37
+ )
38
+ from .errors import NetworkError
39
+ from .fetch import FetchPolicy, FetchProgress, ProgressCallback
40
+ from .service import get_service
41
+ from .service.base import BaseService
42
+ from .service.config import ServiceConfig as ServiceConfigModel
43
+ from .tiles import fetch_tile
44
+ from .types import (
45
+ CRS,
46
+ BBoxTuple,
47
+ BoundingBox,
48
+ Format,
49
+ ServiceTypeEnum,
50
+ TileRequest,
51
+ TileResponse,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from dask.array.core import Array as DaskArray
56
+ else: # pragma: no cover - typing aid
57
+ DaskArray = Any
58
+
59
+ _FETCH_POLICY_ATTR = "tilearray_fetch_policy"
60
+ _RECOMMENDED_NUM_WORKERS_ATTR = "tilearray_recommended_num_workers"
61
+
62
+
63
+ class ArrayRequest(BaseModel):
64
+ service_config: ServiceConfigModel | None = None
65
+ service_url: str
66
+ bbox: BoundingBox
67
+ target_crs: CRS
68
+ chunk_size: tuple[int, int]
69
+ grid_shape: tuple[int, int]
70
+ resolution: tuple[float, float] | None = None
71
+ output_format: Format | None = None
72
+ cache_dir: Path | None = None
73
+ service_options: dict[str, Any] = Field(default_factory=dict)
74
+
75
+ model_config = ConfigDict(arbitrary_types_allowed=True)
76
+
77
+ @property
78
+ def chunk_height(self) -> int:
79
+ return self.chunk_size[0]
80
+
81
+ @property
82
+ def chunk_width(self) -> int:
83
+ return self.chunk_size[1]
84
+
85
+ @property
86
+ def chunk_pixels(self) -> tuple[int, int]:
87
+ return self.chunk_width, self.chunk_height
88
+
89
+ @property
90
+ def cache_path(self) -> Path | None:
91
+ return self.cache_dir
92
+
93
+ @property
94
+ def service_kwargs(self) -> dict[str, Any]:
95
+ kwargs: dict[str, Any] = {}
96
+ if self.service_config is not None:
97
+ return kwargs
98
+ kwargs.update(self.service_options)
99
+ kwargs.setdefault("crs", self.target_crs)
100
+ if self.output_format is not None:
101
+ kwargs.setdefault("output_format", self.output_format)
102
+ return kwargs
103
+
104
+ @classmethod
105
+ def from_inputs(
106
+ cls,
107
+ *,
108
+ service_url: str,
109
+ service_config: ServiceConfigModel | None,
110
+ bbox_input: BoundingBox | BBoxTuple,
111
+ crs_input: CRS | str | int,
112
+ chunk_size_input: tuple[int, int] | None,
113
+ grid_shape_input: tuple[int, int] | None,
114
+ output_format_input: Format | None,
115
+ cache_dir_input: str | Path | None,
116
+ service_options_input: dict[str, Any],
117
+ ) -> ArrayRequest:
118
+ target_crs = _coerce_crs(crs_input)
119
+ normalized_bbox = _normalize_bbox(bbox_input, target_crs)
120
+
121
+ defaults = service_config.array_defaults() if service_config else {}
122
+
123
+ chunk_candidate = chunk_size_input or defaults.get("chunk_size") or (256, 256)
124
+ chunk_height, chunk_width = _validate_chunk_size(chunk_candidate)
125
+
126
+ user_options = dict(service_options_input)
127
+ user_resolution = user_options.pop("resolution", None)
128
+ resolution = user_resolution or defaults.get("resolution")
129
+
130
+ fallback_grid = defaults.get("grid_shape")
131
+ grid = _resolve_grid_shape(grid_shape_input, fallback_grid)
132
+ if (
133
+ grid_shape_input is None
134
+ and fallback_grid is None
135
+ and resolution is not None
136
+ ):
137
+ res_x, res_y = resolution
138
+ if res_x <= 0 or res_y <= 0:
139
+ raise ValueError("resolution values must be positive")
140
+ span_x = normalized_bbox.max_x - normalized_bbox.min_x
141
+ span_y = normalized_bbox.max_y - normalized_bbox.min_y
142
+ cols = (
143
+ max(1, int(math.ceil(span_x / (chunk_width * res_x))))
144
+ if chunk_width > 0
145
+ else 1
146
+ )
147
+ rows = (
148
+ max(1, int(math.ceil(span_y / (chunk_height * res_y))))
149
+ if chunk_height > 0
150
+ else 1
151
+ )
152
+ grid = (rows, cols)
153
+
154
+ cache_candidate = cache_dir_input or defaults.get("cache_dir")
155
+ cache_path = (
156
+ Path(cache_candidate).expanduser().resolve() if cache_candidate else None
157
+ )
158
+
159
+ effective_format = output_format_input
160
+ if (
161
+ effective_format is None
162
+ and service_config
163
+ and service_config.output_format is not None
164
+ ):
165
+ effective_format = service_config.output_format
166
+
167
+ return cls(
168
+ service_config=service_config,
169
+ service_url=service_url,
170
+ bbox=normalized_bbox,
171
+ target_crs=target_crs,
172
+ chunk_size=(chunk_height, chunk_width),
173
+ grid_shape=grid,
174
+ resolution=resolution,
175
+ output_format=effective_format,
176
+ cache_dir=cache_path,
177
+ service_options=user_options,
178
+ )
179
+
180
+ def build_service(self, service_type: ServiceTypeEnum | None) -> BaseService:
181
+ if self.service_config is not None:
182
+ return self.service_config.build_service()
183
+ return get_service(
184
+ self.service_url, service_type=service_type, **self.service_kwargs
185
+ )
186
+
187
+ def effective_format(self, service: BaseService) -> Format | None:
188
+ if self.output_format is not None:
189
+ return self.output_format
190
+ service_format = getattr(service, "output_format", None)
191
+ if isinstance(service_format, Format):
192
+ return service_format
193
+ if isinstance(service_format, str):
194
+ try:
195
+ return Format(service_format)
196
+ except ValueError:
197
+ return None
198
+ return None
199
+
200
+ def tile_options(self) -> dict[str, Any]:
201
+ options: dict[str, Any] = {}
202
+ if self.service_config is not None:
203
+ options.update(self.service_config.tile_kwargs())
204
+ options.update(self.service_options)
205
+ options.setdefault("crs", self.target_crs)
206
+ if self.output_format is not None:
207
+ options.setdefault("output_format", self.output_format)
208
+ options["grid_shape"] = self.grid_shape
209
+ if self.resolution is not None:
210
+ options.setdefault("resolution", self.resolution)
211
+ return options
212
+
213
+ def plan_tile_requests(
214
+ self, service: BaseService
215
+ ) -> tuple[list[TileRequest], dict[str, Any]]:
216
+ tile_options = self.tile_options()
217
+ tile_requests_iter = service.generate_tile_requests(
218
+ self.bbox,
219
+ self.chunk_pixels,
220
+ **dict(tile_options),
221
+ )
222
+ tile_requests = list(tile_requests_iter)
223
+
224
+ inferred_grid = getattr(service, "inferred_grid_shape", None)
225
+ if (
226
+ inferred_grid is not None
227
+ and self.grid_shape == (1, 1)
228
+ and len(tile_requests) > 1
229
+ ):
230
+ self.grid_shape = inferred_grid
231
+
232
+ return tile_requests, tile_options
233
+
234
+ def array_attrs(
235
+ self,
236
+ service: BaseService,
237
+ tile_options: dict[str, Any],
238
+ effective_format: Format | None,
239
+ ) -> dict[str, Any]:
240
+ attrs: dict[str, Any] = {
241
+ "crs": self.target_crs.value,
242
+ "service_url": self.service_url,
243
+ "service_type": service.service_type.value,
244
+ }
245
+ if effective_format is not None:
246
+ attrs["output_format"] = effective_format.value
247
+ coverage_id = tile_options.get("coverage_id") or getattr(
248
+ service, "coverage_id", None
249
+ )
250
+ if coverage_id:
251
+ attrs["coverage_id"] = coverage_id
252
+ return attrs
253
+
254
+
255
+ def _resolve_decoder(
256
+ request: ArrayRequest, service: BaseService, explicit: TileDecoder | None
257
+ ) -> TileDecoder:
258
+ if explicit is not None:
259
+ return explicit
260
+
261
+ fmt = request.effective_format(service)
262
+ if fmt is not None:
263
+ decoder = _decoder_for_format(fmt)
264
+ if decoder is not None:
265
+ return decoder
266
+
267
+ raise RuntimeError(
268
+ f"No tile decoder available for format {fmt!r}; please provide tile_decoder."
269
+ )
270
+
271
+
272
+ def _delayed_call(func: Callable[..., Any], *args: Any) -> Delayed:
273
+ """Typed helper around ``dask.delayed`` to satisfy static analysis."""
274
+
275
+ return cast(Delayed, delayed(func)(*args))
276
+
277
+
278
+ def _organize_tiles(
279
+ tile_requests: Sequence[TileRequest],
280
+ rows: int,
281
+ cols: int,
282
+ ) -> list[list[TileRequest]]:
283
+ if len(tile_requests) != rows * cols:
284
+ raise ValueError(
285
+ f"Service produced {len(tile_requests)} tile requests; expected {rows * cols}"
286
+ )
287
+
288
+ annotated: list[tuple[TileRequest, BoundingBox]] = []
289
+ for request in tile_requests:
290
+ if request.bbox is None:
291
+ raise ValueError("TileRequest is missing spatial metadata (bbox)")
292
+ annotated.append((request, request.bbox))
293
+
294
+ sorted_tiles = [
295
+ request
296
+ for request, _ in sorted(
297
+ annotated,
298
+ key=lambda item: (-item[1].max_y, item[1].min_x),
299
+ )
300
+ ]
301
+
302
+ grid: list[list[TileRequest]] = []
303
+ idx = 0
304
+ for _ in range(rows):
305
+ row_tiles: list[TileRequest] = []
306
+ for _ in range(cols):
307
+ row_tiles.append(sorted_tiles[idx])
308
+ idx += 1
309
+ grid.append(row_tiles)
310
+ return grid
311
+
312
+
313
+ def _fetch_policy_summary(policy: FetchPolicy) -> dict[str, Any]:
314
+ """JSON-serializable fetch policy snapshot for xarray attrs."""
315
+
316
+ return {
317
+ "max_concurrent": policy.max_concurrent,
318
+ "max_connections": policy.max_connections,
319
+ "retries": policy.retries,
320
+ "timeout": policy.timeout,
321
+ "rate_limit_per_second": policy.rate_limit_per_second,
322
+ "adaptive_concurrency": policy.adaptive_concurrency,
323
+ "initial_concurrent": policy.initial_concurrent,
324
+ "min_concurrent": policy.min_concurrent,
325
+ "multiplicative_decrease": policy.multiplicative_decrease,
326
+ "additive_increase": policy.additive_increase,
327
+ "forbidden_circuit_breaker": policy.forbidden_circuit_breaker,
328
+ "forbidden_window_seconds": policy.forbidden_window_seconds,
329
+ "forbidden_threshold": policy.forbidden_threshold,
330
+ "forbidden_cooldown_seconds": policy.forbidden_cooldown_seconds,
331
+ }
332
+
333
+
334
+ def _fetch_policy_from_summary(summary: dict[str, Any]) -> FetchPolicy:
335
+ defaults = FetchPolicy()
336
+ return FetchPolicy(
337
+ max_concurrent=int(summary.get("max_concurrent", defaults.max_concurrent)),
338
+ max_connections=summary.get("max_connections", defaults.max_connections),
339
+ retries=int(summary.get("retries", defaults.retries)),
340
+ timeout=float(summary.get("timeout", defaults.timeout)),
341
+ rate_limit_per_second=summary.get(
342
+ "rate_limit_per_second", defaults.rate_limit_per_second
343
+ ),
344
+ adaptive_concurrency=bool(
345
+ summary.get("adaptive_concurrency", defaults.adaptive_concurrency)
346
+ ),
347
+ initial_concurrent=int(
348
+ summary.get("initial_concurrent", defaults.initial_concurrent)
349
+ ),
350
+ min_concurrent=int(summary.get("min_concurrent", defaults.min_concurrent)),
351
+ multiplicative_decrease=float(
352
+ summary.get("multiplicative_decrease", defaults.multiplicative_decrease)
353
+ ),
354
+ additive_increase=int(
355
+ summary.get("additive_increase", defaults.additive_increase)
356
+ ),
357
+ forbidden_circuit_breaker=bool(
358
+ summary.get("forbidden_circuit_breaker", defaults.forbidden_circuit_breaker)
359
+ ),
360
+ forbidden_window_seconds=float(
361
+ summary.get("forbidden_window_seconds", defaults.forbidden_window_seconds)
362
+ ),
363
+ forbidden_threshold=int(
364
+ summary.get("forbidden_threshold", defaults.forbidden_threshold)
365
+ ),
366
+ forbidden_cooldown_seconds=float(
367
+ summary.get(
368
+ "forbidden_cooldown_seconds", defaults.forbidden_cooldown_seconds
369
+ )
370
+ ),
371
+ )
372
+
373
+
374
+ def _attrs_from_compute_target(obj: Any) -> dict[str, Any] | None:
375
+ attrs = getattr(obj, "attrs", None)
376
+ if isinstance(attrs, dict):
377
+ return attrs
378
+ return None
379
+
380
+
381
+ def _resolve_fetch_policy_for_compute(
382
+ obj: Any,
383
+ *,
384
+ fetch_policy: FetchPolicy | None = None,
385
+ max_concurrent: int | None = None,
386
+ ) -> FetchPolicy:
387
+ if fetch_policy is not None:
388
+ return fetch_policy
389
+ if max_concurrent is not None:
390
+ return FetchPolicy(max_concurrent=max_concurrent)
391
+ attrs = _attrs_from_compute_target(obj)
392
+ if attrs is not None:
393
+ summary = attrs.get(_FETCH_POLICY_ATTR)
394
+ if isinstance(summary, dict):
395
+ return _fetch_policy_from_summary(summary)
396
+ return FetchPolicy()
397
+
398
+
399
+ def compute_thread_pool_size(
400
+ fetch_policy: FetchPolicy,
401
+ *,
402
+ override: int | None = None,
403
+ ) -> int:
404
+ """
405
+ Thread-pool size for Dask's ``threads`` scheduler when computing tile arrays.
406
+
407
+ Dask defaults to ``cpu_count()`` workers, which can cap in-flight tile fetches
408
+ below :attr:`~tilearray.fetch.FetchPolicy.max_concurrent`. Pass the result as
409
+ ``num_workers`` when calling :meth:`xarray.DataArray.compute` manually.
410
+ """
411
+
412
+ if override is not None:
413
+ return max(1, override)
414
+ cpu_count = os.cpu_count() or 1
415
+ return max(cpu_count, fetch_policy.max_concurrent)
416
+
417
+
418
+ def compute_with_policy(
419
+ obj: Any,
420
+ fetch_policy: FetchPolicy | None = None,
421
+ *,
422
+ max_concurrent: int | None = None,
423
+ num_workers: int | None = None,
424
+ **compute_kwargs: Any,
425
+ ) -> Any:
426
+ """
427
+ Compute a Dask-backed xarray or dask object with fetch-aligned thread workers.
428
+
429
+ Dask's default ``.compute()`` uses ``num_workers=cpu_count()``, which can cap
430
+ in-flight tile fetches below :attr:`~tilearray.fetch.FetchPolicy.max_concurrent`
431
+ when AIMD allows more parallel HTTP. This helper runs the threaded scheduler with
432
+ ``num_workers=compute_thread_pool_size(policy)``.
433
+
434
+ Policy resolution order: explicit ``fetch_policy``, then ``max_concurrent`` (builds
435
+ a minimal policy), then ``tilearray_fetch_policy`` on ``obj.attrs`` (set by
436
+ :func:`create_array`), else :class:`~tilearray.fetch.FetchPolicy` defaults.
437
+ """
438
+
439
+ policy = _resolve_fetch_policy_for_compute(
440
+ obj,
441
+ fetch_policy=fetch_policy,
442
+ max_concurrent=max_concurrent,
443
+ )
444
+
445
+ if num_workers is None and fetch_policy is None and max_concurrent is None:
446
+ attrs = _attrs_from_compute_target(obj)
447
+ if attrs is not None:
448
+ recommended = attrs.get(_RECOMMENDED_NUM_WORKERS_ATTR)
449
+ if recommended is not None:
450
+ num_workers = int(recommended)
451
+
452
+ workers = compute_thread_pool_size(policy, override=num_workers)
453
+ merged = dict(compute_kwargs)
454
+ merged["scheduler"] = "threads"
455
+ merged["num_workers"] = workers
456
+ return obj.compute(**merged)
457
+
458
+
459
+ def _resolve_fetch_policy(
460
+ service_config: ServiceConfigModel | None,
461
+ service_options: dict[str, Any],
462
+ ) -> FetchPolicy:
463
+ base = (
464
+ service_config.fetch_policy() if service_config is not None else FetchPolicy()
465
+ )
466
+ return FetchPolicy(
467
+ max_concurrent=service_options.pop(
468
+ "max_concurrent_requests", base.max_concurrent
469
+ ),
470
+ max_connections=service_options.pop("max_connections", base.max_connections),
471
+ retries=service_options.pop("fetch_retries", base.retries),
472
+ timeout=service_options.pop("fetch_timeout", base.timeout),
473
+ rate_limit_per_second=service_options.pop(
474
+ "rate_limit_per_second", base.rate_limit_per_second
475
+ ),
476
+ rate_limiter=service_options.pop("rate_limiter", base.rate_limiter),
477
+ adaptive_concurrency=service_options.pop(
478
+ "adaptive_concurrency", base.adaptive_concurrency
479
+ ),
480
+ initial_concurrent=service_options.pop(
481
+ "initial_concurrent_requests", base.initial_concurrent
482
+ ),
483
+ min_concurrent=service_options.pop(
484
+ "min_concurrent_requests", base.min_concurrent
485
+ ),
486
+ multiplicative_decrease=service_options.pop(
487
+ "multiplicative_decrease", base.multiplicative_decrease
488
+ ),
489
+ forbidden_circuit_breaker=service_options.pop(
490
+ "forbidden_circuit_breaker", base.forbidden_circuit_breaker
491
+ ),
492
+ forbidden_window_seconds=service_options.pop(
493
+ "forbidden_window_seconds", base.forbidden_window_seconds
494
+ ),
495
+ forbidden_threshold=service_options.pop(
496
+ "forbidden_threshold", base.forbidden_threshold
497
+ ),
498
+ forbidden_cooldown_seconds=service_options.pop(
499
+ "forbidden_cooldown_seconds", base.forbidden_cooldown_seconds
500
+ ),
501
+ )
502
+
503
+
504
+ def create_array(
505
+ service_url: str | ServiceConfigModel,
506
+ bbox: BoundingBox | BBoxTuple,
507
+ crs: CRS | str | int,
508
+ *,
509
+ service_type: ServiceTypeEnum | None = None,
510
+ chunk_size: tuple[int, int] | None = None,
511
+ grid_shape: tuple[int, int] | None = None,
512
+ output_format: Format | None = None,
513
+ cache_dir: str | Path | None = None,
514
+ compute: bool = False,
515
+ compute_num_workers: int | None = None,
516
+ dtype: str | np.dtype[Any] = np.dtype("float32"),
517
+ tile_decoder: TileDecoder | None = None,
518
+ on_progress: ProgressCallback | None = None,
519
+ **service_options: Any,
520
+ ) -> xr.DataArray:
521
+ """
522
+ Create an xarray ``DataArray`` backed by Dask from a remote service.
523
+
524
+ When ``compute=True``, tile fetches run on Dask's threaded scheduler with
525
+ ``num_workers=compute_thread_pool_size(fetch_policy)`` (override via
526
+ ``compute_num_workers``) so AIMD can use the full fetch concurrency ceiling.
527
+
528
+ For lazy arrays (``compute=False``), prefer :func:`compute_with_policy` over
529
+ plain ``.compute()`` so Dask worker count matches the fetch ceiling. Fetch
530
+ policy metadata is stored on ``DataArray.attrs`` for that path.
531
+ """
532
+
533
+ target_crs = _coerce_crs(crs)
534
+ normalized_bbox = _normalize_bbox(bbox, target_crs)
535
+
536
+ service_config = (
537
+ service_url if isinstance(service_url, ServiceConfigModel) else None
538
+ )
539
+ base_service_url = (
540
+ service_config.base_url if service_config else cast(str, service_url)
541
+ )
542
+
543
+ if (
544
+ service_config
545
+ and service_type is not None
546
+ and service_type != service_config.service_type
547
+ ):
548
+ raise ValueError(
549
+ "Provided service_type does not match the ServiceConfig service_type"
550
+ )
551
+
552
+ options = dict(service_options)
553
+ fetch_policy = _resolve_fetch_policy(service_config, options)
554
+
555
+ request = ArrayRequest.from_inputs(
556
+ service_url=base_service_url,
557
+ service_config=service_config,
558
+ bbox_input=bbox,
559
+ crs_input=crs,
560
+ chunk_size_input=chunk_size,
561
+ grid_shape_input=grid_shape,
562
+ output_format_input=output_format,
563
+ cache_dir_input=cache_dir,
564
+ service_options_input=options,
565
+ )
566
+
567
+ service = request.build_service(service_type)
568
+ decoder = _resolve_decoder(request, service, tile_decoder)
569
+
570
+ tile_requests, tile_options = request.plan_tile_requests(service)
571
+
572
+ rows, cols = request.grid_shape
573
+ tile_grid = _organize_tiles(tile_requests, rows, cols)
574
+
575
+ cache_path = request.cache_path
576
+ chunk_height, chunk_width = request.chunk_size
577
+ dtype_np = np.dtype(dtype)
578
+ progress = FetchProgress(total=len(tile_requests), on_progress=on_progress)
579
+
580
+ effective_format = request.effective_format(service)
581
+ n_bands = _probe_tile_band_count(
582
+ tile_requests[0],
583
+ cache_path,
584
+ decoder,
585
+ dtype_np,
586
+ fetch_policy,
587
+ effective_format,
588
+ )
589
+
590
+ blocks: list[list[DaskArray]] = []
591
+ for row_tiles in tile_grid:
592
+ row_blocks: list[DaskArray] = []
593
+ for tile_request in row_tiles:
594
+ height = tile_request.height or chunk_height
595
+ width = tile_request.width or chunk_width
596
+ delayed_tile = _delayed_call(
597
+ _load_tile_array,
598
+ tile_request,
599
+ cache_path,
600
+ decoder,
601
+ dtype_np,
602
+ fetch_policy,
603
+ progress,
604
+ )
605
+ block_shape = (height, width, n_bands) if n_bands > 1 else (height, width)
606
+ row_blocks.append(
607
+ da_from_delayed(
608
+ delayed_tile,
609
+ shape=block_shape,
610
+ dtype=dtype_np,
611
+ )
612
+ )
613
+ blocks.append(row_blocks)
614
+
615
+ data = _assemble_tile_mosaic(blocks, n_bands)
616
+
617
+ attrs = request.array_attrs(service, tile_options, effective_format)
618
+ attrs[_FETCH_POLICY_ATTR] = _fetch_policy_summary(fetch_policy)
619
+ attrs[_RECOMMENDED_NUM_WORKERS_ATTR] = compute_thread_pool_size(fetch_policy)
620
+
621
+ normalized_bbox = request.bbox
622
+ target_crs = request.target_crs
623
+ # Row 0 comes from the northernmost tiles (_organize_tiles sorts by -max_y).
624
+ y_coords = np.linspace(normalized_bbox.max_y, normalized_bbox.min_y, data.shape[0])
625
+ x_coords = np.linspace(normalized_bbox.min_x, normalized_bbox.max_x, data.shape[1])
626
+
627
+ coords: dict[str, Any] = {"y": y_coords, "x": x_coords}
628
+ dims: tuple[str, ...] = ("y", "x")
629
+ if n_bands > 1:
630
+ coords["band"] = np.arange(n_bands)
631
+ dims = ("y", "x", "band")
632
+
633
+ data_array = xr.DataArray(
634
+ data,
635
+ coords=coords,
636
+ dims=dims,
637
+ attrs=attrs,
638
+ )
639
+
640
+ if compute:
641
+ num_workers = compute_thread_pool_size(
642
+ fetch_policy, override=compute_num_workers
643
+ )
644
+ return data_array.compute(
645
+ scheduler="threads",
646
+ num_workers=num_workers,
647
+ )
648
+ return data_array
649
+
650
+
651
+ def load_array(*args: Any, compute: bool = True, **kwargs: Any) -> xr.DataArray:
652
+ """Convenience wrapper around :func:`create_array`."""
653
+
654
+ return create_array(*args, compute=compute, **kwargs)
655
+
656
+
657
+ def _coerce_crs(crs: CRS | str | int) -> CRS:
658
+ if isinstance(crs, CRS):
659
+ return crs
660
+ if isinstance(crs, str):
661
+ crs_upper = crs.upper()
662
+ return (
663
+ CRS.from_epsg(crs_upper)
664
+ if crs_upper.startswith("EPSG:")
665
+ else CRS.from_integer(int(crs))
666
+ )
667
+ return CRS.from_integer(crs)
668
+
669
+
670
+ def _normalize_bbox(bbox: BoundingBox | BBoxTuple, crs: CRS) -> BoundingBox:
671
+ if isinstance(bbox, BoundingBox):
672
+ return bbox if bbox.crs == crs else bbox.to_crs(crs)
673
+ return BoundingBox.from_tuple(bbox, crs)
674
+
675
+
676
+ def _resolve_grid_shape(
677
+ explicit: tuple[int, int] | None,
678
+ fallback: tuple[int, int] | None,
679
+ ) -> tuple[int, int]:
680
+ if explicit is not None:
681
+ return _validate_grid_shape(explicit)
682
+ if fallback is not None:
683
+ return _validate_grid_shape(fallback)
684
+ return (1, 1)
685
+
686
+
687
+ def _validate_grid_shape(grid: tuple[int, int]) -> tuple[int, int]:
688
+ rows, cols = grid
689
+ if rows <= 0 or cols <= 0:
690
+ raise ValueError("grid_shape must contain positive integers")
691
+ return rows, cols
692
+
693
+
694
+ def _validate_chunk_size(chunk_size: tuple[int, int]) -> tuple[int, int]:
695
+ width, height = chunk_size
696
+ if width <= 0 or height <= 0:
697
+ raise ValueError("chunk_size dimensions must be positive integers")
698
+ return height, width
699
+
700
+
701
+ def _assemble_tile_mosaic(
702
+ blocks: list[list[DaskArray]],
703
+ n_bands: int,
704
+ ) -> DaskArray:
705
+ """
706
+ Stitch decoded tile blocks into a single mosaic array.
707
+
708
+ ``da.block`` maps the tile grid onto the leading axes of each block. For
709
+ multi-band tiles ``(y, x, band)`` that incorrectly concatenates along
710
+ ``band`` instead of ``x``. Concatenate explicitly on spatial axes instead.
711
+ """
712
+
713
+ if n_bands > 1:
714
+ row_arrays = [
715
+ cast(DaskArray, da_concatenate(row_blocks, axis=1)) for row_blocks in blocks
716
+ ]
717
+ return cast(DaskArray, da_concatenate(row_arrays, axis=0))
718
+ return cast(DaskArray, da_block(blocks))
719
+
720
+
721
+ def _probe_tile_band_count(
722
+ sample_request: TileRequest,
723
+ cache_dir: Path | None,
724
+ decoder: TileDecoder,
725
+ dtype: np.dtype[Any],
726
+ fetch_policy: FetchPolicy | None,
727
+ effective_format: Format | None,
728
+ ) -> int:
729
+ """Determine band count before building the Dask graph."""
730
+
731
+ fixed = default_band_count_for_format(effective_format)
732
+ if fixed is not None:
733
+ return fixed
734
+
735
+ sample = _load_tile_array(
736
+ sample_request,
737
+ cache_dir,
738
+ decoder,
739
+ dtype,
740
+ fetch_policy,
741
+ )
742
+ return band_count_from_array(sample)
743
+
744
+
745
+ def _resize_tile_array(
746
+ array: NDArrayFloat, target_height: int, target_width: int
747
+ ) -> NDArrayFloat:
748
+ if array.ndim == 3:
749
+ resized_bands = [
750
+ _resize_tile_array(array[..., band], target_height, target_width)
751
+ for band in range(array.shape[2])
752
+ ]
753
+ return cast(NDArrayFloat, np.stack(resized_bands, axis=-1))
754
+
755
+ actual_height, actual_width = array.shape
756
+
757
+ if actual_height == target_height and actual_width == target_width:
758
+ return array
759
+
760
+ if target_height <= 0 or target_width <= 0:
761
+ raise ValueError("target dimensions must be positive")
762
+
763
+ if actual_height < target_height or actual_width < target_width:
764
+ raise ValueError(
765
+ f"Decoded tile has shape {(actual_height, actual_width)}, expected at least {(target_height, target_width)}"
766
+ )
767
+
768
+ if actual_height % target_height == 0 and actual_width % target_width == 0:
769
+ factor_y = actual_height // target_height
770
+ factor_x = actual_width // target_width
771
+ reshaped = array.reshape(target_height, factor_y, target_width, factor_x)
772
+ return cast(NDArrayFloat, np.nanmean(reshaped, axis=(1, 3)))
773
+
774
+ y_edges = np.linspace(0, actual_height, target_height + 1, dtype=int)
775
+ x_edges = np.linspace(0, actual_width, target_width + 1, dtype=int)
776
+ y_edges[0] = 0
777
+ y_edges[-1] = actual_height
778
+ x_edges[0] = 0
779
+ x_edges[-1] = actual_width
780
+
781
+ resized = np.empty((target_height, target_width), dtype=array.dtype)
782
+ for row in range(target_height):
783
+ y_start, y_end = y_edges[row], y_edges[row + 1]
784
+ for col in range(target_width):
785
+ x_start, x_end = x_edges[col], x_edges[col + 1]
786
+ block = array[y_start:y_end, x_start:x_end]
787
+ resized[row, col] = np.nanmean(block) if block.size else np.nan
788
+ return resized
789
+
790
+
791
+ def _load_tile_array(
792
+ request: TileRequest,
793
+ cache_dir: Path | None,
794
+ decoder: TileDecoder,
795
+ dtype: np.dtype[Any],
796
+ fetch_policy: FetchPolicy | None = None,
797
+ progress: FetchProgress | None = None,
798
+ ) -> NDArrayFloat:
799
+ if progress is not None:
800
+ progress.check_not_aborted()
801
+ response = _fetch_with_cache(request, cache_dir, fetch_policy, progress)
802
+ if progress is not None:
803
+ progress.tick(request, response)
804
+ if not response.success:
805
+ message = response.error_message or f"HTTP {response.status_code}"
806
+ if progress is not None:
807
+ progress.abort(message)
808
+ raise NetworkError(f"Tile fetch failed for {request.url}: {message}")
809
+
810
+ array = decoder(response, request)
811
+ if array.ndim not in (2, 3):
812
+ raise ValueError("tile_decoder must return a 2D or 3D (y, x[, band]) array")
813
+
814
+ target_height = request.height or array.shape[0]
815
+ target_width = request.width or array.shape[1]
816
+
817
+ array = _resize_tile_array(array, target_height, target_width)
818
+
819
+ return np.asarray(array, dtype=dtype)
820
+
821
+
822
+ def _fetch_with_cache(
823
+ request: TileRequest,
824
+ cache_dir: Path | None,
825
+ fetch_policy: FetchPolicy | None = None,
826
+ progress: FetchProgress | None = None,
827
+ ) -> TileResponse:
828
+ if progress is not None:
829
+ progress.check_not_aborted()
830
+ if cache_dir is not None:
831
+ cached = _read_cache(cache_dir, request)
832
+ if cached is not None:
833
+ cached_data, cached_content_type = cached
834
+ return TileResponse(
835
+ data=cached_data,
836
+ content_type=cached_content_type,
837
+ status_code=200,
838
+ headers={},
839
+ url=request.url,
840
+ success=True,
841
+ error_message=None,
842
+ )
843
+
844
+ response = fetch_tile(request, policy=fetch_policy, progress=progress)
845
+ if cache_dir is not None and response.success:
846
+ cached_data = bytes(response.data)
847
+ if cached_data:
848
+ _write_cache(cache_dir, request, cached_data, response.content_type)
849
+ return response
850
+
851
+
852
+ def _cache_key(request: TileRequest) -> str:
853
+ payload = {
854
+ "url": request.url,
855
+ "params": sorted((str(k), str(v)) for k, v in request.params.items()),
856
+ "format": request.output_format.value if request.output_format else None,
857
+ "bbox": request.bbox.model_dump() if request.bbox else None,
858
+ "width": request.width,
859
+ "height": request.height,
860
+ }
861
+ serialized = json.dumps(payload, sort_keys=True)
862
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
863
+
864
+
865
+ def _read_cache(cache_dir: Path, request: TileRequest) -> tuple[bytes, str] | None:
866
+ cache_dir.mkdir(parents=True, exist_ok=True)
867
+ key = _cache_key(request)
868
+ path = cache_dir / f"{key}.tile"
869
+ if not path.exists():
870
+ return None
871
+
872
+ data = path.read_bytes()
873
+ meta_path = cache_dir / f"{key}.meta"
874
+ if meta_path.exists():
875
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
876
+ stored_type = str(meta.get("content_type", ""))
877
+ elif request.output_format is not None:
878
+ stored_type = request.output_format.value
879
+ else:
880
+ stored_type = ""
881
+ content_type = sniff_effective_content_type(stored_type, data)
882
+ return data, content_type
883
+
884
+
885
+ def _write_cache(
886
+ cache_dir: Path,
887
+ request: TileRequest,
888
+ data: bytes,
889
+ content_type: str,
890
+ ) -> None:
891
+ cache_dir.mkdir(parents=True, exist_ok=True)
892
+ key = _cache_key(request)
893
+ path = cache_dir / f"{key}.tile"
894
+ path.write_bytes(data)
895
+ meta_path = cache_dir / f"{key}.meta"
896
+ meta_path.write_text(
897
+ json.dumps({"content_type": content_type}, sort_keys=True),
898
+ encoding="utf-8",
899
+ )