morecantile 6.2.0__py3-none-any.whl → 7.0.1__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.
morecantile/models.py CHANGED
@@ -1,9 +1,11 @@
1
1
  """Pydantic modules for OGC TileMatrixSets (https://www.ogc.org/standards/tms)"""
2
2
 
3
3
  import math
4
+ import os
4
5
  import warnings
5
- from functools import cached_property
6
- from typing import Any, Dict, Iterator, List, Literal, Optional, Sequence, Tuple, Union
6
+ from collections.abc import Iterator, Sequence
7
+ from functools import cached_property, lru_cache
8
+ from typing import Any, Literal
7
9
 
8
10
  import pyproj
9
11
  from pydantic import (
@@ -16,7 +18,7 @@ from pydantic import (
16
18
  field_validator,
17
19
  model_validator,
18
20
  )
19
- from pyproj.exceptions import CRSError, ProjError
21
+ from pyproj.exceptions import CRSError
20
22
  from typing_extensions import Annotated
21
23
 
22
24
  from morecantile.commons import BoundingBox, Coords, Tile
@@ -35,12 +37,17 @@ from morecantile.utils import (
35
37
  meters_per_unit,
36
38
  point_in_bbox,
37
39
  to_rasterio_crs,
40
+ truncate_coordinates,
38
41
  )
39
42
 
40
- NumType = Union[float, int]
41
- BoundsType = Tuple[NumType, NumType]
43
+ NumType = float | int
44
+ BoundsType = tuple[NumType, NumType]
42
45
  LL_EPSILON = 1e-11
43
- axesInfo = Annotated[List[str], Field(min_length=2, max_length=2)]
46
+ axesInfo = Annotated[list[str], Field(min_length=2, max_length=2)]
47
+ WGS84_CRS = pyproj.CRS.from_epsg(4326)
48
+ DEFAULT_GEOGRAPHIC_CRS = os.environ.get("MORECANTILE_DEFAULT_GEOGRAPHIC_CRS")
49
+
50
+ TransformerFromCRS = lru_cache(pyproj.Transformer.from_crs)
44
51
 
45
52
 
46
53
  class CRSUri(BaseModel):
@@ -64,7 +71,7 @@ class CRSWKT(BaseModel):
64
71
  """Coordinate Reference System (CRS) from WKT encoded as PROJJSON Object."""
65
72
 
66
73
  wkt: Annotated[
67
- Dict,
74
+ dict,
68
75
  Field(
69
76
  json_schema_extra={
70
77
  "description": "An object defining the CRS using the JSON encoding for Well-known text representation of coordinate reference systems 2.0",
@@ -77,7 +84,7 @@ class CRSRef(BaseModel):
77
84
  """CRS from referenceSystem."""
78
85
 
79
86
  referenceSystem: Annotated[
80
- Dict[str, Any],
87
+ dict[str, Any],
81
88
  Field(
82
89
  json_schema_extra={
83
90
  "description": "A reference system data structure as defined in the MD_ReferenceSystem of the ISO 19115",
@@ -86,7 +93,7 @@ class CRSRef(BaseModel):
86
93
  ]
87
94
 
88
95
 
89
- class CRS(RootModel[Union[str, Union[CRSUri, CRSWKT, CRSRef]]]):
96
+ class CRS(RootModel[str | CRSUri | CRSWKT | CRSRef]):
90
97
  """CRS model.
91
98
 
92
99
  Ref: https://github.com/opengeospatial/ogcapi-tiles/blob/master/openapi/schemas/common-geodata/crs.yaml
@@ -119,7 +126,7 @@ class CRS(RootModel[Union[str, Union[CRSUri, CRSWKT, CRSRef]]]):
119
126
  """return the string form of the user input used to create the CRS."""
120
127
  return self._pyproj_crs.srs
121
128
 
122
- def to_epsg(self, *args: Any, **kwargs: Any) -> Optional[int]:
129
+ def to_epsg(self, *args: Any, **kwargs: Any) -> int | None:
123
130
  """return EPSG number of the CRS."""
124
131
  return self._pyproj_crs.to_epsg(*args, **kwargs)
125
132
 
@@ -131,7 +138,7 @@ class CRS(RootModel[Union[str, Union[CRSUri, CRSWKT, CRSRef]]]):
131
138
  """return PROJ4 version of the CRS."""
132
139
  return self._pyproj_crs.to_proj4(*args, **kwargs)
133
140
 
134
- def to_dict(self) -> Dict:
141
+ def to_dict(self) -> dict:
135
142
  """return DICT version of the CRS."""
136
143
  return self._pyproj_crs.to_dict()
137
144
 
@@ -165,7 +172,7 @@ def crs_axis_inverted(crs: pyproj.CRS) -> bool:
165
172
  return crs.axis_info[0].abbrev.upper() in ["Y", "LAT", "N"]
166
173
 
167
174
 
168
- def ordered_axis_inverted(ordered_axes: List[str]) -> bool:
175
+ def ordered_axis_inverted(ordered_axes: list[str]) -> bool:
169
176
  """Check if ordered axes have inverted AXIS (lat,lon) instead of (lon,lat)."""
170
177
  return ordered_axes[0].upper() in ["Y", "LAT", "N"]
171
178
 
@@ -194,7 +201,7 @@ class TMSBoundingBox(BaseModel, arbitrary_types_allowed=True):
194
201
  ),
195
202
  ]
196
203
  crs: Annotated[
197
- Optional[CRS],
204
+ CRS | None,
198
205
  Field(
199
206
  json_schema_extra={
200
207
  "description": "Coordinate Reference System (CRS)",
@@ -202,7 +209,7 @@ class TMSBoundingBox(BaseModel, arbitrary_types_allowed=True):
202
209
  ),
203
210
  ] = None
204
211
  orderedAxes: Annotated[
205
- Optional[axesInfo],
212
+ axesInfo | None,
206
213
  Field(
207
214
  json_schema_extra={
208
215
  "description": "Ordered list of names of the dimensions defined in the CRS",
@@ -258,7 +265,7 @@ class TileMatrix(BaseModel, extra="forbid"):
258
265
  """
259
266
 
260
267
  title: Annotated[
261
- Optional[str],
268
+ str | None,
262
269
  Field(
263
270
  json_schema_extra={
264
271
  "description": "Title of this tile matrix, normally used for display to a human",
@@ -266,7 +273,7 @@ class TileMatrix(BaseModel, extra="forbid"):
266
273
  ),
267
274
  ] = None
268
275
  description: Annotated[
269
- Optional[str],
276
+ str | None,
270
277
  Field(
271
278
  json_schema_extra={
272
279
  "description": "Brief narrative description of this tile matrix set, normally available for display to a human",
@@ -274,7 +281,7 @@ class TileMatrix(BaseModel, extra="forbid"):
274
281
  ),
275
282
  ] = None
276
283
  keywords: Annotated[
277
- Optional[List[str]],
284
+ list[str] | None,
278
285
  Field(
279
286
  json_schema_extra={
280
287
  "description": "Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this dataset",
@@ -363,7 +370,7 @@ class TileMatrix(BaseModel, extra="forbid"):
363
370
  ),
364
371
  ]
365
372
  variableMatrixWidths: Annotated[
366
- Optional[List[variableMatrixWidth]],
373
+ list[variableMatrixWidth] | None,
367
374
  Field(
368
375
  json_schema_extra={
369
376
  "description": "Describes the rows that has variable matrix width",
@@ -391,7 +398,7 @@ class TileMatrix(BaseModel, extra="forbid"):
391
398
  return 1
392
399
 
393
400
 
394
- class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
401
+ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True, extra="ignore"):
395
402
  """Tile Matrix Set Definition
396
403
 
397
404
  A definition of a tile matrix set following the Tile Matrix Set standard.
@@ -403,52 +410,58 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
403
410
  """
404
411
 
405
412
  title: Annotated[
406
- Optional[str],
413
+ str | None,
407
414
  Field(
408
415
  json_schema_extra={
409
416
  "description": "Title of this tile matrix set, normally used for display to a human",
410
- }
417
+ },
418
+ frozen=True,
411
419
  ),
412
420
  ] = None
413
421
  description: Annotated[
414
- Optional[str],
422
+ str | None,
415
423
  Field(
416
424
  json_schema_extra={
417
425
  "description": "Brief narrative description of this tile matrix set, normally available for display to a human",
418
- }
426
+ },
427
+ frozen=True,
419
428
  ),
420
429
  ] = None
421
430
  keywords: Annotated[
422
- Optional[List[str]],
431
+ list[str] | None,
423
432
  Field(
424
433
  json_schema_extra={
425
434
  "description": "Unordered list of one or more commonly used or formalized word(s) or phrase(s) used to describe this tile matrix set",
426
- }
435
+ },
436
+ frozen=True,
427
437
  ),
428
438
  ] = None
429
439
  id: Annotated[
430
- Optional[str],
440
+ str | None,
431
441
  Field(
432
442
  pattern=r"^[\w\d_\-]+$",
433
443
  json_schema_extra={
434
444
  "description": "Tile matrix set identifier. Implementation of 'identifier'",
435
445
  },
446
+ frozen=True,
436
447
  ),
437
448
  ] = None
438
449
  uri: Annotated[
439
- Optional[str],
450
+ str | None,
440
451
  Field(
441
452
  json_schema_extra={
442
453
  "description": "Reference to an official source for this tileMatrixSet",
443
- }
454
+ },
455
+ frozen=True,
444
456
  ),
445
457
  ] = None
446
458
  orderedAxes: Annotated[
447
- Optional[axesInfo],
459
+ axesInfo | None,
448
460
  Field(
449
461
  json_schema_extra={
450
462
  "description": "Ordered list of names of the dimensions defined in the CRS",
451
- }
463
+ },
464
+ frozen=True,
452
465
  ),
453
466
  ] = None
454
467
  crs: Annotated[
@@ -456,39 +469,41 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
456
469
  Field(
457
470
  json_schema_extra={
458
471
  "description": "Coordinate Reference System (CRS)",
459
- }
472
+ },
473
+ frozen=True,
460
474
  ),
461
475
  ]
462
476
  wellKnownScaleSet: Annotated[
463
- Optional[AnyHttpUrl],
477
+ AnyHttpUrl | None,
464
478
  Field(
465
479
  json_schema_extra={
466
480
  "description": "Reference to a well-known scale set",
467
- }
481
+ },
482
+ frozen=True,
468
483
  ),
469
484
  ] = None
470
485
  boundingBox: Annotated[
471
- Optional[TMSBoundingBox],
486
+ TMSBoundingBox | None,
472
487
  Field(
473
488
  json_schema_extra={
474
489
  "description": "Minimum bounding rectangle surrounding the tile matrix set, in the supported CRS",
475
- }
490
+ },
491
+ frozen=True,
476
492
  ),
477
493
  ] = None
478
494
  tileMatrices: Annotated[
479
- List[TileMatrix],
495
+ list[TileMatrix],
480
496
  Field(
481
497
  json_schema_extra={
482
498
  "description": "Describes scale levels and its tile matrices",
483
- }
499
+ },
500
+ frozen=True,
484
501
  ),
485
502
  ]
486
503
 
487
504
  # Private attributes
488
- _to_geographic: pyproj.Transformer = PrivateAttr()
489
- _from_geographic: pyproj.Transformer = PrivateAttr()
490
-
491
- _tile_matrices_idx: Dict[int, int] = PrivateAttr()
505
+ _geographic_crs: pyproj.CRS = PrivateAttr()
506
+ _tile_matrices_idx: dict[int, int] = PrivateAttr()
492
507
 
493
508
  def __init__(self, **data):
494
509
  """Set private attributes."""
@@ -498,22 +513,12 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
498
513
  int(mat.id): idx for idx, mat in enumerate(self.tileMatrices)
499
514
  }
500
515
 
501
- try:
502
- self._to_geographic = pyproj.Transformer.from_crs(
503
- self.crs._pyproj_crs, self.crs._pyproj_crs.geodetic_crs, always_xy=True
504
- )
505
- self._from_geographic = pyproj.Transformer.from_crs(
506
- self.crs._pyproj_crs.geodetic_crs, self.crs._pyproj_crs, always_xy=True
507
- )
508
- except ProjError:
509
- warnings.warn(
510
- "Could not create coordinate Transformer from input CRS to the given geographic CRS"
511
- "some methods might not be available.",
512
- UserWarning,
513
- stacklevel=1,
514
- )
515
- self._to_geographic = None
516
- self._from_geographic = None
516
+ # Default Geographic CRS from TMS's CRS
517
+ self._geographic_crs = (
518
+ pyproj.CRS.from_user_input(DEFAULT_GEOGRAPHIC_CRS)
519
+ if DEFAULT_GEOGRAPHIC_CRS
520
+ else self.crs._pyproj_crs.geodetic_crs
521
+ )
517
522
 
518
523
  @model_validator(mode="before")
519
524
  def check_for_old_specification(cls, data):
@@ -551,20 +556,36 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
551
556
  """Simplify default pydantic model repr."""
552
557
  return f"<TileMatrixSet title='{self.title}' id='{self.id}' crs='{CRS_to_uri(self.crs._pyproj_crs)}>"
553
558
 
554
- @cached_property
555
- def geographic_crs(self) -> pyproj.CRS:
556
- """Return the TMS's geographic CRS."""
557
- return self.crs._pyproj_crs.geodetic_crs
558
-
559
559
  @cached_property
560
560
  def rasterio_crs(self):
561
561
  """Return rasterio CRS."""
562
562
  return to_rasterio_crs(self.crs._pyproj_crs)
563
563
 
564
- @cached_property
564
+ def set_geographic_crs(self, crs: pyproj.CRS) -> None:
565
+ """Overwrite Geographic CRS for the TMS."""
566
+ self._geographic_crs = crs
567
+
568
+ @property
569
+ def _to_geographic(self) -> pyproj.Transformer:
570
+ return TransformerFromCRS(
571
+ self.crs._pyproj_crs, self.geographic_crs, always_xy=True
572
+ )
573
+
574
+ @property
575
+ def _from_geographic(self) -> pyproj.Transformer:
576
+ return TransformerFromCRS(
577
+ self.geographic_crs, self.crs._pyproj_crs, always_xy=True
578
+ )
579
+
580
+ @property
581
+ def geographic_crs(self) -> pyproj.CRS:
582
+ """Return the TMS's geographic CRS."""
583
+ return self._geographic_crs
584
+
585
+ @property
565
586
  def rasterio_geographic_crs(self):
566
587
  """Return the geographic CRS as a rasterio CRS."""
567
- return to_rasterio_crs(self.crs._pyproj_crs.geodetic_crs)
588
+ return to_rasterio_crs(self._geographic_crs)
568
589
 
569
590
  @property
570
591
  def minzoom(self) -> int:
@@ -586,7 +607,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
586
607
  )
587
608
 
588
609
  @classmethod
589
- def from_v1(cls, tms: Dict) -> "TileMatrixSet":
610
+ def from_v1(cls, tms: dict) -> "TileMatrixSet":
590
611
  """
591
612
  Makes a TMS from a v1 TMS definition
592
613
 
@@ -606,7 +627,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
606
627
  WKSS URL
607
628
  boundingBox: TMSBoundingBox (optional)
608
629
  Bounding box of TMS
609
- tileMatrix: List[TileMatrix]
630
+ tileMatrix: list[TileMatrix]
610
631
  List of Tile Matrices
611
632
 
612
633
  Returns:
@@ -644,19 +665,21 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
644
665
  @classmethod
645
666
  def custom(
646
667
  cls,
647
- extent: List[float],
668
+ extent: list[float],
648
669
  crs: pyproj.CRS,
649
670
  tile_width: int = 256,
650
671
  tile_height: int = 256,
651
- matrix_scale: Optional[List] = None,
652
- extent_crs: Optional[pyproj.CRS] = None,
672
+ matrix_scale: list | None = None,
673
+ extent_crs: pyproj.CRS | None = None,
653
674
  minzoom: int = 0,
654
675
  maxzoom: int = 24,
655
- title: Optional[str] = None,
656
- id: Optional[str] = None,
657
- ordered_axes: Optional[List[str]] = None,
676
+ title: str | None = None,
677
+ id: str | None = None,
678
+ ordered_axes: list[str] | None = None,
658
679
  screen_pixel_size: float = 0.28e-3,
659
680
  decimation_base: int = 2,
681
+ corner_of_origin: Literal["topLeft", "bottomLeft"] = "topLeft",
682
+ point_of_origin: list[float] = None,
660
683
  **kwargs: Any,
661
684
  ):
662
685
  """
@@ -664,10 +687,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
664
687
 
665
688
  Attributes
666
689
  ----------
667
- crs: pyproj.CRS
668
- Tile Matrix Set coordinate reference system
669
690
  extent: list
670
691
  Bounding box of the Tile Matrix Set, (left, bottom, right, top).
692
+ crs: pyproj.CRS
693
+ Tile Matrix Set coordinate reference system
671
694
  tile_width: int
672
695
  Width of each tile of this tile matrix in pixels (default is 256).
673
696
  tile_height: int
@@ -693,6 +716,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
693
716
  Rendering pixel size. 0.28 mm was the actual pixel size of a common display from 2005 and considered as standard by OGC.
694
717
  decimation_base: int, optional
695
718
  How tiles are divided at each zoom level (default is 2). Must be greater than 1.
719
+ corner_of_origin: str, optional
720
+ Corner of origin for the TMS, either 'topLeft' or 'bottomLeft'
721
+ point_of_origin: list, optional
722
+ Point of origin for the TMS, (x, y) coordinates in the TMS CRS.
696
723
  kwargs: Any
697
724
  Attributes to forward to the TileMatrixSet
698
725
 
@@ -710,7 +737,10 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
710
737
 
711
738
  if extent_crs:
712
739
  transform = pyproj.Transformer.from_crs(extent_crs, crs, always_xy=True)
713
- extent = transform.transform_bounds(*extent, densify_pts=21)
740
+ left, bottom, right, top = extent
741
+ extent = list(
742
+ transform.transform_bounds(left, bottom, right, top, densify_pts=21)
743
+ )
714
744
 
715
745
  if decimation_base <= 1:
716
746
  raise ValueError(
@@ -718,13 +748,25 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
718
748
  )
719
749
 
720
750
  bbox = BoundingBox(*extent)
721
- x_origin = bbox.left if not is_inverted else bbox.top
722
- y_origin = bbox.top if not is_inverted else bbox.left
751
+ if not point_of_origin:
752
+ if corner_of_origin == "topLeft":
753
+ x_origin = bbox.left if not is_inverted else bbox.top
754
+ y_origin = bbox.top if not is_inverted else bbox.left
755
+ point_of_origin = [x_origin, y_origin]
756
+ elif corner_of_origin == "bottomLeft":
757
+ x_origin = bbox.left if not is_inverted else bbox.bottom
758
+ y_origin = bbox.bottom if not is_inverted else bbox.left
759
+ point_of_origin = [x_origin, y_origin]
760
+ else:
761
+ raise ValueError(
762
+ f"Invalid `corner_of_origin` value: {corner_of_origin}, must be either 'topLeft' or 'bottomLeft'"
763
+ )
764
+
723
765
  width = abs(bbox.right - bbox.left)
724
766
  height = abs(bbox.top - bbox.bottom)
725
767
  mpu = meters_per_unit(crs)
726
768
 
727
- tile_matrices: List[TileMatrix] = []
769
+ tile_matrices: list[TileMatrix] = []
728
770
  for zoom in range(minzoom, maxzoom + 1):
729
771
  res = max(
730
772
  width / (tile_width * matrix_scale[0]) / float(decimation_base) ** zoom,
@@ -738,7 +780,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
738
780
  "id": str(zoom),
739
781
  "scaleDenominator": res * mpu / screen_pixel_size,
740
782
  "cellSize": res,
741
- "pointOfOrigin": [x_origin, y_origin],
783
+ "cornerOfOrigin": corner_of_origin,
784
+ "pointOfOrigin": point_of_origin,
742
785
  "tileWidth": tile_width,
743
786
  "tileHeight": tile_height,
744
787
  "matrixWidth": matrix_scale[0] * decimation_base**zoom,
@@ -810,6 +853,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
810
853
  id=str(int(tile_matrix.id) + 1),
811
854
  scaleDenominator=tile_matrix.scaleDenominator / factor,
812
855
  cellSize=tile_matrix.cellSize / factor,
856
+ cornerOfOrigin=tile_matrix.cornerOfOrigin,
813
857
  pointOfOrigin=tile_matrix.pointOfOrigin,
814
858
  tileWidth=tile_matrix.tileWidth,
815
859
  tileHeight=tile_matrix.tileHeight,
@@ -832,9 +876,9 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
832
876
  def zoom_for_res(
833
877
  self,
834
878
  res: float,
835
- max_z: Optional[int] = None,
879
+ max_z: int | None = None,
836
880
  zoom_level_strategy: str = "auto",
837
- min_z: Optional[int] = None,
881
+ min_z: int | None = None,
838
882
  ) -> int:
839
883
  """Get TMS zoom level corresponding to a specific resolution.
840
884
 
@@ -887,7 +931,17 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
887
931
 
888
932
  return zoom_level
889
933
 
890
- def lnglat(self, x: float, y: float, truncate=False) -> Coords:
934
+ def intersect_tms(self, bbox: BoundingBox) -> bool:
935
+ """Check if a bounds intersects with the TMS bounds."""
936
+ tms_bounds = self.xy_bbox
937
+ return (
938
+ (bbox[0] < tms_bounds[2])
939
+ and (bbox[2] > tms_bounds[0])
940
+ and (bbox[3] > tms_bounds[1])
941
+ and (bbox[1] < tms_bounds[3])
942
+ )
943
+
944
+ def lnglat(self, x: float, y: float, truncate: bool = False) -> Coords:
891
945
  """Transform point(x,y) to geographic longitude and latitude."""
892
946
  inside = point_in_bbox(Coords(x, y), self.xy_bbox)
893
947
  if not inside:
@@ -900,14 +954,14 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
900
954
  lng, lat = self._to_geographic.transform(x, y)
901
955
 
902
956
  if truncate:
903
- lng, lat = self.truncate_lnglat(lng, lat)
957
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
904
958
 
905
959
  return Coords(lng, lat)
906
960
 
907
- def xy(self, lng: float, lat: float, truncate=False) -> Coords:
961
+ def xy(self, lng: float, lat: float, truncate: bool = False) -> Coords:
908
962
  """Transform geographic longitude and latitude coordinates to TMS CRS."""
909
963
  if truncate:
910
- lng, lat = self.truncate_lnglat(lng, lat)
964
+ lng, lat = truncate_coordinates(lng, lat, self.bbox)
911
965
 
912
966
  inside = point_in_bbox(Coords(lng, lat), self.bbox)
913
967
  if not inside:
@@ -921,25 +975,6 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
921
975
 
922
976
  return Coords(x, y)
923
977
 
924
- def truncate_lnglat(self, lng: float, lat: float) -> Tuple[float, float]:
925
- """
926
- Truncate geographic coordinates to TMS geographic bbox.
927
-
928
- Adapted from https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py
929
-
930
- """
931
- if lng > self.bbox.right:
932
- lng = self.bbox.right
933
- elif lng < self.bbox.left:
934
- lng = self.bbox.left
935
-
936
- if lat > self.bbox.top:
937
- lat = self.bbox.top
938
- elif lat < self.bbox.bottom:
939
- lat = self.bbox.bottom
940
-
941
- return lng, lat
942
-
943
978
  def _tile(
944
979
  self,
945
980
  xcoord: float,
@@ -970,8 +1005,14 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
970
1005
  if not math.isinf(xcoord)
971
1006
  else 0
972
1007
  )
1008
+
1009
+ coord = (
1010
+ (origin_y - ycoord)
1011
+ if matrix.cornerOfOrigin == "topLeft"
1012
+ else (ycoord - origin_y)
1013
+ )
973
1014
  ytile = (
974
- math.floor((origin_y - ycoord) / float(matrix.cellSize * matrix.tileHeight))
1015
+ math.floor(coord / float(matrix.cellSize * matrix.tileHeight))
975
1016
  if not math.isinf(ycoord)
976
1017
  else 0
977
1018
  )
@@ -1007,6 +1048,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1007
1048
  zoom: int,
1008
1049
  truncate=False,
1009
1050
  ignore_coalescence: bool = False,
1051
+ geographic_crs: pyproj.CRS | None = None,
1010
1052
  ) -> Tile:
1011
1053
  """
1012
1054
  Get the tile for a given geographic longitude and latitude pair.
@@ -1019,13 +1061,41 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1019
1061
  The zoom level.
1020
1062
  truncate : bool
1021
1063
  Whether or not to truncate inputs to limits of TMS geographic bounds.
1064
+ ignore_coalescence : bool
1065
+ Whether or not to ignore coalescence factor for TMS with variable matrix width.
1066
+ geographic_crs: pyproj.CRS, optional
1067
+ Geographic CRS of the given coordinates. Default to TMS's Geographic CRS.
1022
1068
 
1023
1069
  Returns
1024
1070
  -------
1025
1071
  Tile
1026
1072
 
1027
1073
  """
1028
- x, y = self.xy(lng, lat, truncate=truncate)
1074
+ geographic_crs = geographic_crs or self.geographic_crs or WGS84_CRS
1075
+ _from_geographic = TransformerFromCRS(
1076
+ geographic_crs, self.crs._pyproj_crs, always_xy=True
1077
+ )
1078
+ _to_geographic = TransformerFromCRS(
1079
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1080
+ )
1081
+
1082
+ if truncate:
1083
+ left, bottom, right, top = self.xy_bbox
1084
+ bbox = BoundingBox(
1085
+ *_to_geographic.transform_bounds(
1086
+ left, bottom, right, top, densify_pts=21
1087
+ ),
1088
+ )
1089
+ lng, lat = truncate_coordinates(lng, lat, bbox)
1090
+
1091
+ x, y = _from_geographic.transform(lng, lat)
1092
+ if not point_in_bbox(Coords(x, y), self.xy_bbox):
1093
+ warnings.warn(
1094
+ f"Point ({lng}, {lat}) is outside TMS bounds.",
1095
+ PointOutsideTMSBounds,
1096
+ stacklevel=1,
1097
+ )
1098
+
1029
1099
  return self._tile(x, y, zoom, ignore_coalescence=ignore_coalescence)
1030
1100
 
1031
1101
  def _ul(self, *tile: Tile) -> Coords:
@@ -1051,10 +1121,16 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1051
1121
  if matrix.variableMatrixWidths is not None
1052
1122
  else 1
1053
1123
  )
1054
- return Coords(
1055
- origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth,
1056
- origin_y - t.y * matrix.cellSize * matrix.tileHeight,
1124
+ x_coord = (
1125
+ origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1057
1126
  )
1127
+ y_coord = (
1128
+ origin_y - t.y * matrix.cellSize * matrix.tileHeight
1129
+ if matrix.cornerOfOrigin == "topLeft"
1130
+ else origin_y + t.y * matrix.cellSize * matrix.tileHeight
1131
+ )
1132
+
1133
+ return Coords(x_coord, y_coord)
1058
1134
 
1059
1135
  def _lr(self, *tile: Tile) -> Coords:
1060
1136
  """
@@ -1079,12 +1155,18 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1079
1155
  if matrix.variableMatrixWidths is not None
1080
1156
  else 1
1081
1157
  )
1082
- return Coords(
1158
+ x_coord = (
1083
1159
  origin_x
1084
- + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth,
1085
- origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight,
1160
+ + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1161
+ )
1162
+ y_coord = (
1163
+ origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1164
+ if matrix.cornerOfOrigin == "topLeft"
1165
+ else origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
1086
1166
  )
1087
1167
 
1168
+ return Coords(x_coord, y_coord)
1169
+
1088
1170
  def xy_bounds(self, *tile: Tile) -> BoundingBox:
1089
1171
  """
1090
1172
  Return the bounding box of the tile in TMS coordinate reference system.
@@ -1110,12 +1192,16 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1110
1192
  )
1111
1193
 
1112
1194
  left = origin_x + math.floor(t.x / cf) * matrix.cellSize * cf * matrix.tileWidth
1113
- top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1114
1195
  right = (
1115
1196
  origin_x
1116
1197
  + (math.floor(t.x / cf) + 1) * matrix.cellSize * cf * matrix.tileWidth
1117
1198
  )
1118
- bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1199
+ if matrix.cornerOfOrigin == "topLeft":
1200
+ top = origin_y - t.y * matrix.cellSize * matrix.tileHeight
1201
+ bottom = origin_y - (t.y + 1) * matrix.cellSize * matrix.tileHeight
1202
+ else:
1203
+ bottom = origin_y + t.y * matrix.cellSize * matrix.tileHeight
1204
+ top = origin_y + (t.y + 1) * matrix.cellSize * matrix.tileHeight
1119
1205
 
1120
1206
  return BoundingBox(left, bottom, right, top)
1121
1207
 
@@ -1174,7 +1260,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1174
1260
 
1175
1261
  return BoundingBox(left, bottom, right, top)
1176
1262
 
1177
- @property
1263
+ @cached_property
1178
1264
  def xy_bbox(self):
1179
1265
  """Return TMS bounding box in TileMatrixSet's CRS."""
1180
1266
  zoom = self.minzoom
@@ -1186,7 +1272,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1186
1272
  )
1187
1273
  return BoundingBox(left, bottom, right, top)
1188
1274
 
1189
- @cached_property
1275
+ @property
1190
1276
  def bbox(self):
1191
1277
  """Return TMS bounding box in geographic coordinate reference system."""
1192
1278
  left, bottom, right, top = self.xy_bbox
@@ -1200,16 +1286,6 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1200
1286
  )
1201
1287
  )
1202
1288
 
1203
- def intersect_tms(self, bbox: BoundingBox) -> bool:
1204
- """Check if a bounds intersects with the TMS bounds."""
1205
- tms_bounds = self.xy_bbox
1206
- return (
1207
- (bbox[0] < tms_bounds[2])
1208
- and (bbox[2] > tms_bounds[0])
1209
- and (bbox[3] > tms_bounds[1])
1210
- and (bbox[1] < tms_bounds[3])
1211
- )
1212
-
1213
1289
  def tiles( # noqa: C901
1214
1290
  self,
1215
1291
  west: float,
@@ -1218,6 +1294,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1218
1294
  north: float,
1219
1295
  zooms: Sequence[int],
1220
1296
  truncate: bool = False,
1297
+ geographic_crs: pyproj.CRS | None = None,
1221
1298
  ) -> Iterator[Tile]:
1222
1299
  """
1223
1300
  Get the tiles overlapped by a geographic bounding box
@@ -1232,6 +1309,8 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1232
1309
  One or more zoom levels.
1233
1310
  truncate : bool, optional
1234
1311
  Whether or not to truncate inputs to TMS limits.
1312
+ geographic_crs: pyproj.CRS, optional
1313
+ Geographic CRS of the given coordinates. Default to TMS's Geographic CRS
1235
1314
 
1236
1315
  Yields
1237
1316
  ------
@@ -1249,39 +1328,44 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1249
1328
  if isinstance(zooms, int):
1250
1329
  zooms = (zooms,)
1251
1330
 
1331
+ geographic_crs = geographic_crs or self.geographic_crs or WGS84_CRS
1332
+ _from_geographic = TransformerFromCRS(
1333
+ geographic_crs, self.crs._pyproj_crs, always_xy=True
1334
+ )
1335
+ _to_geographic = TransformerFromCRS(
1336
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1337
+ )
1338
+
1339
+ # TMS bbox
1340
+ left, bottom, right, top = self.xy_bbox
1341
+ bbox = BoundingBox(
1342
+ *_to_geographic.transform_bounds(left, bottom, right, top, densify_pts=21),
1343
+ )
1344
+
1252
1345
  if truncate:
1253
- west, south = self.truncate_lnglat(west, south)
1254
- east, north = self.truncate_lnglat(east, north)
1346
+ west, south = truncate_coordinates(west, south, bbox)
1347
+ east, north = truncate_coordinates(east, north, bbox)
1255
1348
 
1256
1349
  if west > east:
1257
- bbox_west = (self.bbox.left, south, east, north)
1258
- bbox_east = (west, south, self.bbox.right, north)
1350
+ bbox_west = (bbox.left, south, east, north)
1351
+ bbox_east = (west, south, bbox.right, north)
1259
1352
  bboxes = [bbox_west, bbox_east]
1260
1353
  else:
1261
1354
  bboxes = [(west, south, east, north)]
1262
1355
 
1263
1356
  for w, s, e, n in bboxes:
1264
1357
  # Clamp bounding values.
1265
- es_contain_180th = lons_contain_antimeridian(e, self.bbox.right)
1266
- w = max(self.bbox.left, w)
1267
- s = max(self.bbox.bottom, s)
1268
- e = max(self.bbox.right, e) if es_contain_180th else min(self.bbox.right, e)
1269
- n = min(self.bbox.top, n)
1270
-
1358
+ es_contain_180th = lons_contain_antimeridian(e, bbox.right)
1359
+ w = max(bbox.left, w)
1360
+ s = max(bbox.bottom, s)
1361
+ e = max(bbox.right, e) if es_contain_180th else min(bbox.right, e)
1362
+ n = min(bbox.top, n)
1363
+
1364
+ w, n = _from_geographic.transform(w + LL_EPSILON, n - LL_EPSILON)
1365
+ e, s = _from_geographic.transform(e - LL_EPSILON, s + LL_EPSILON)
1271
1366
  for z in zooms:
1272
- nw_tile = self.tile(
1273
- w + LL_EPSILON,
1274
- n - LL_EPSILON,
1275
- z,
1276
- ignore_coalescence=True,
1277
- ) # Not in mercantile
1278
- se_tile = self.tile(
1279
- e - LL_EPSILON,
1280
- s + LL_EPSILON,
1281
- z,
1282
- ignore_coalescence=True,
1283
- )
1284
-
1367
+ nw_tile = self._tile(w, n, z, ignore_coalescence=True)
1368
+ se_tile = self._tile(e, s, z, ignore_coalescence=True)
1285
1369
  minx = min(nw_tile.x, se_tile.x)
1286
1370
  maxx = max(nw_tile.x, se_tile.x)
1287
1371
  miny = min(nw_tile.y, se_tile.y)
@@ -1303,12 +1387,13 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1303
1387
  def feature(
1304
1388
  self,
1305
1389
  tile: Tile,
1306
- fid: Optional[str] = None,
1307
- props: Optional[Dict] = None,
1308
- buffer: Optional[NumType] = None,
1309
- precision: Optional[int] = None,
1390
+ fid: str | None = None,
1391
+ props: dict | None = None,
1392
+ buffer: NumType | None = None,
1393
+ precision: int | None = None,
1310
1394
  projected: bool = False,
1311
- ) -> Dict:
1395
+ geographic_crs: pyproj.CRS | None = None,
1396
+ ) -> dict:
1312
1397
  """
1313
1398
  Get the GeoJSON feature corresponding to a tile.
1314
1399
 
@@ -1329,16 +1414,27 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1329
1414
  otherwise original coordinate values will be preserved (default).
1330
1415
  projected : bool, optional
1331
1416
  Return coordinates in TMS projection. Default is false.
1417
+ geographic_crs: pyproj.CRS, optional
1418
+ Geographic CRS to use when `projected=False`. Default to 'EPSG:4326' as per GeoJSON specification.
1419
+ .
1332
1420
 
1333
1421
  Returns
1334
1422
  -------
1335
1423
  dict
1336
1424
 
1337
1425
  """
1426
+ geographic_crs = geographic_crs or WGS84_CRS
1427
+
1428
+ feature_crs = self.crs._pyproj_crs
1338
1429
  west, south, east, north = self.xy_bounds(tile)
1339
1430
 
1340
1431
  if not projected:
1341
- west, south, east, north = self._to_geographic.transform_bounds(
1432
+ feature_crs = geographic_crs
1433
+ tr = pyproj.Transformer.from_crs(
1434
+ self.crs._pyproj_crs, geographic_crs, always_xy=True
1435
+ )
1436
+
1437
+ west, south, east, north = tr.transform_bounds(
1342
1438
  west, south, east, north, densify_pts=21
1343
1439
  )
1344
1440
 
@@ -1357,33 +1453,45 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1357
1453
  geom = bbox_to_feature(west, south, east, north)
1358
1454
 
1359
1455
  xyz = str(tile)
1360
- feat: Dict[str, Any] = {
1456
+ feat: dict[str, Any] = {
1361
1457
  "type": "Feature",
1362
1458
  "bbox": bbox,
1363
1459
  "id": xyz,
1364
1460
  "geometry": geom,
1365
1461
  "properties": {
1366
1462
  "title": f"XYZ tile {xyz}",
1367
- "grid_name": self.id,
1368
- "grid_crs": CRS_to_uri(self.crs._pyproj_crs),
1463
+ "tms": self.id,
1464
+ "tms_crs": CRS_to_uri(self.crs._pyproj_crs),
1369
1465
  },
1370
1466
  }
1371
1467
 
1372
- if projected:
1468
+ if feature_crs != WGS84_CRS:
1373
1469
  warnings.warn(
1374
1470
  "CRS is no longer part of the GeoJSON specification."
1375
1471
  "Other projection than EPSG:4326 might not be supported.",
1376
1472
  UserWarning,
1377
1473
  stacklevel=1,
1378
1474
  )
1379
- feat.update(
1380
- {
1381
- "crs": {
1382
- "type": "EPSG",
1383
- "properties": {"code": self.crs.to_epsg()},
1475
+
1476
+ if authority_code := feature_crs.to_authority(min_confidence=20):
1477
+ authority, code = authority_code
1478
+ feat.update(
1479
+ {
1480
+ "crs": {
1481
+ "type": "name",
1482
+ "properties": {"name": CRS_to_uri(feature_crs)},
1483
+ }
1384
1484
  }
1385
- }
1386
- )
1485
+ )
1486
+ else:
1487
+ feat.update(
1488
+ {
1489
+ "crs": {
1490
+ "type": "wkt",
1491
+ "properties": {"wkt": feature_crs.to_wkt()},
1492
+ }
1493
+ }
1494
+ )
1387
1495
 
1388
1496
  if props:
1389
1497
  feat["properties"].update(props)
@@ -1461,7 +1569,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1461
1569
 
1462
1570
  return Tile(xtile, ytile, i + 1)
1463
1571
 
1464
- def minmax(self, zoom: int) -> Dict:
1572
+ def minmax(self, zoom: int) -> dict:
1465
1573
  """Return TileMatrix Extrema.
1466
1574
 
1467
1575
  Parameters
@@ -1471,7 +1579,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1471
1579
 
1472
1580
  Returns
1473
1581
  -------
1474
- Dict
1582
+ dict
1475
1583
 
1476
1584
  """
1477
1585
  m = self.matrix(zoom)
@@ -1480,11 +1588,12 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1480
1588
  "y": {"min": 0, "max": m.matrixHeight - 1},
1481
1589
  }
1482
1590
 
1483
- def is_valid(self, *tile: Tile) -> bool:
1591
+ def is_valid(self, *tile: Tile, strict: bool = True) -> bool:
1484
1592
  """Check if a tile is valid."""
1485
1593
  t = _parse_tile_arg(*tile)
1486
1594
 
1487
- if t.z < self.minzoom:
1595
+ disable_overzoom = self.is_variable or strict
1596
+ if t.z < self.minzoom or (disable_overzoom and t.z > self.maxzoom):
1488
1597
  return False
1489
1598
 
1490
1599
  matrix = self.matrix(t.z)
@@ -1493,7 +1602,7 @@ class TileMatrixSet(BaseModel, arbitrary_types_allowed=True):
1493
1602
 
1494
1603
  return validx and validy
1495
1604
 
1496
- def neighbors(self, *tile: Tile) -> List[Tile]:
1605
+ def neighbors(self, *tile: Tile) -> list[Tile]:
1497
1606
  """The neighbors of a tile
1498
1607
 
1499
1608
  The neighbors function makes no guarantees regarding neighbor tile