Coverage for gws-app/gws/lib/crs/__init__.py: 93%
364 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Coordinate reference systems.
3This package provides ``gws.Crs`` objects, which describe coordinate reference systems
4and transform extents, points and resolutions between them. CRS objects are created
5from EPSG definitions with ``pyproj``, and are cached, so that there is one object per SRID.
6Only CRSs with meter or degree units are supported. CRS objects with the same SRID compare equal.
8``transform_resolution`` samples nine points of the extent (corners, edge midpoints, centre),
9transforms each together with a one-pixel step in x and in y, and returns the smallest
10finite positive step length in the target CRS.
12A CRS can be referenced by a ``gws.CrsName`` in one of these formats (see ``gws.CrsFormat``):
14- numeric SRID: ``4326``
15- EPSG code: ``EPSG:4326``
16- OGC HTTP URL: ``http://www.opengis.net/gml/srs/epsg.xml#4326``
17- OGC experimental URN: ``urn:x-ogc:def:crs:EPSG:4326``
18- OGC URN: ``urn:ogc:def:crs:EPSG::4326``
19- OGC HTTP URI: ``http://www.opengis.net/def/crs/EPSG/0/4326``
21Names are case-insensitive. Some aliases, like ``CRS:84`` or ``EPSG:900913``, are also recognized.
23The package provides:
25- predefined CRS objects ``WGS84`` and ``WEBMERCATOR`` and related constants,
26- ``get``, ``require`` and ``parse`` to look up a CRS by name,
27- ``best_match`` to pick a CRS from a list of supported CRSs,
28- ``qgis_extent_width`` to compute the width of a geographic extent the way QGIS does.
30The ``gws.Crs`` interface itself is defined in ``types.pyinc``.
32Example::
34 import gws.lib.crs
36 crs = gws.lib.crs.require('EPSG:25832')
37 crs.to_string(gws.CrsFormat.urn) # 'urn:ogc:def:crs:EPSG::25832'
39 ext = gws.lib.crs.WGS84.transform_extent((5.0, 47.0, 15.0, 55.0), crs)
41 fmt, crs = gws.lib.crs.parse('urn:ogc:def:crs:EPSG::4326')
42 crs.axis_for_format(fmt) # gws.Axis.yx
43"""
45from typing import Optional
47import math
48import re
49import warnings
51import pyproj.crs
52import pyproj.exceptions
53import pyproj.transformer
55import gws
58##
61class Object(gws.Crs):
62 """Coordinate reference system."""
64 def __init__(self, **kwargs):
65 """Create a CRS object with the given attributes.
67 Args:
68 **kwargs: Attribute values, see ``gws.Crs``.
69 """
70 vars(self).update(kwargs)
72 # crs objects with the same srid must be equal
73 # (despite caching, they can be different due to pickling)
75 def __hash__(self):
76 return self.srid
78 def __eq__(self, other):
79 return isinstance(other, Object) and other.srid == self.srid
81 def __repr__(self):
82 return f'<crs:{self.srid}>'
84 def axis_for_format(self, fmt):
85 if not self.isYX:
86 return self.axis
87 return _AXIS_FOR_FORMAT.get(fmt, self.axis)
89 def transform_extent(self, ext, crs_to):
90 if crs_to == self:
91 return ext
92 return _transform_extent_check(ext, self.srid, crs_to.srid)
94 def transform_resolution(self, extent, res, crs_to):
95 tr = self.transformer(crs_to)
97 x0, y0, x1, y1 = extent
98 xm = (x0 + x1) / 2
99 ym = (y0 + y1) / 2
101 points = [
102 (x0, y0),
103 (xm, y0),
104 (x1, y0),
105 (x0, ym),
106 (xm, ym),
107 (x1, ym),
108 (x0, y1),
109 (xm, y1),
110 (x1, y1),
111 ]
113 ds = []
114 for x, y in points:
115 ax, ay = tr(x, y)
116 bx, by = tr(x + res, y)
117 cx, cy = tr(x, y + res)
118 ds.append(math.hypot(bx - ax, by - ay))
119 ds.append(math.hypot(cx - ax, cy - ay))
121 ds = [d for d in ds if math.isfinite(d) and d > 0]
122 return min(ds) if ds else 0.0
124 def clip_wgs_extent(self, wgs_extent):
125 a = wgs_extent
126 b = self.wgsMaxExtent
127 x0, y0, x1, y1 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
128 if x0 >= x1 or y0 >= y1:
129 return None
130 return x0, y0, x1, y1
132 def transformer(self, crs_to):
133 tr = _pyproj_transformer(self.srid, crs_to.srid)
134 return tr.transform
136 def extent_size_in_meters(self, extent):
137 x0, y0, x1, y1 = extent
139 if self.isProjected:
140 if self.uom != gws.Uom.m:
141 # @TODO support non-meter crs
142 raise Error(f'unsupported unit: {self.uom}')
143 return abs(x1 - x0), abs(y1 - y0)
145 geod = pyproj.Geod(ellps='WGS84')
147 mid_lat = (y0 + y1) / 2
148 _, _, w = geod.inv(x0, mid_lat, x1, mid_lat)
149 mid_lon = (x0 + x1) / 2
150 _, _, h = geod.inv(mid_lon, y0, mid_lon, y1)
152 return w, h
154 def point_offset_in_meters(self, xy, dist, az):
155 x, y = xy
157 if self.isProjected:
158 if self.uom != gws.Uom.m:
159 # @TODO support non-meter crs
160 raise Error(f'unsupported unit: {self.uom}')
162 if az == 0:
163 return x, y + dist
164 if az == 90:
165 return x + dist, y
166 if az == 180:
167 return x, y - dist
168 if az == 270:
169 return x - dist, y
171 az_rad = math.radians(90 - az)
172 return (
173 x + dist * math.cos(az_rad),
174 y + dist * math.sin(az_rad),
175 )
177 geod = pyproj.Geod(ellps='WGS84')
178 x, y, _ = geod.fwd(x, y, dist=dist, az=az)
179 return x, y
181 def to_string(self, fmt=None):
182 fmt = fmt or gws.CrsFormat.epsg
183 if fmt == gws.CrsFormat.srid:
184 return str(self.srid)
185 return getattr(self, str(fmt).lower())
187 def to_geojson(self):
188 # https://geojson.org/geojson-spec#named-crs
189 return {
190 'type': 'name',
191 'properties': {
192 'name': self.urn,
193 },
194 }
197##
200def qgis_extent_width(extent: gws.Extent) -> float:
201 """Compute the width of a geographic extent in meters, the way QGIS does.
203 This is a port of ``QgsScaleCalculator::calculateGeographicDistance`` from QGIS.
204 The distance is measured along the middle latitude of the extent.
206 Args:
207 extent: Extent in degrees.
209 Returns:
210 The width in meters.
211 """
212 # straight port from QGIS/src/core/qgsscalecalculator.cpp QgsScaleCalculator::calculateGeographicDistance
213 x0, y0, x1, y1 = extent
215 lat = (y0 + y1) * 0.5
216 RADS = (4.0 * math.atan(1.0)) / 180.0
217 a = math.pow(math.cos(lat * RADS), 2)
218 c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
219 RA = 6378000
220 E = 0.0810820288
221 radius = RA * (1.0 - E * E) / math.pow(1.0 - E * E * math.sin(lat * RADS) * math.sin(lat * RADS), 1.5)
222 return (x1 - x0) / 180.0 * radius * c
225##
227# enough precision to represent 1cm
228COORDINATE_PRECISION_DEG = 7
229COORDINATE_PRECISION_M = 2
231WGS84: gws.Crs = Object(
232 srid=4326,
233 proj4text='+proj=longlat +datum=WGS84 +no_defs +type=crs',
234 wkt='GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],CS[ellipsoidal,2],AXIS["geodetic latitude (Lat)",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["geodetic longitude (Lon)",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]],USAGE[SCOPE["Horizontal component of 3D system."],AREA["World."],BBOX[-90,-180,90,180]],ID["EPSG",4326]]',
235 axis=gws.Axis.yx,
236 uom=gws.Uom.deg,
237 isGeographic=True,
238 isProjected=False,
239 isYX=True,
240 epsg='EPSG:4326',
241 urn='urn:ogc:def:crs:EPSG::4326',
242 urnx='urn:x-ogc:def:crs:EPSG:4326',
243 url='http://www.opengis.net/gml/srs/epsg.xml#4326',
244 uri='http://www.opengis.net/def/crs/epsg/0/4326',
245 name='WGS 84',
246 base=0,
247 datum='World Geodetic System 1984 ensemble',
248 wgsExtent=(-180, -90, 180, 90),
249 extent=(-180, -90, 180, 90),
250 wgsMaxExtent=(-180, -90, 180, 90),
251 coordinatePrecision=COORDINATE_PRECISION_DEG,
252)
253"""WGS 84 geographic CRS (EPSG:4326)."""
255WGS84.bounds = gws.Bounds(crs=WGS84, extent=WGS84.extent)
257WEBMERCATOR: gws.Crs = Object(
258 srid=3857,
259 proj4text='+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs',
260 wkt='PROJCRS["WGS 84 / Pseudo-Mercator",BASEGEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],ID["EPSG",4326]],CONVERSION["Popular Visualisation Pseudo-Mercator",METHOD["Popular Visualisation Pseudo Mercator",ID["EPSG",1024]],PARAMETER["Latitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8801]],PARAMETER["Longitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8802]],PARAMETER["False easting",0,LENGTHUNIT["metre",1],ID["EPSG",8806]],PARAMETER["False northing",0,LENGTHUNIT["metre",1],ID["EPSG",8807]]],CS[Cartesian,2],AXIS["easting (X)",east,ORDER[1],LENGTHUNIT["metre",1]],AXIS["northing (Y)",north,ORDER[2],LENGTHUNIT["metre",1]],USAGE[SCOPE["Web mapping and visualisation."],AREA["World between 85.06°S and 85.06°N."],BBOX[-85.06,-180,85.06,180]],ID["EPSG",3857]]',
261 axis=gws.Axis.xy,
262 uom=gws.Uom.m,
263 isGeographic=False,
264 isProjected=True,
265 isYX=False,
266 epsg='EPSG:3857',
267 urn='urn:ogc:def:crs:EPSG::3857',
268 urnx='urn:x-ogc:def:crs:EPSG:3857',
269 url='http://www.opengis.net/gml/srs/epsg.xml#3857',
270 uri='http://www.opengis.net/def/crs/epsg/0/3857',
271 name='WGS 84 / Pseudo-Mercator',
272 base=4326,
273 datum='World Geodetic System 1984 ensemble',
274 wgsExtent=(-180, -85.06, 180, 85.06),
275 extent=(
276 -20037508.342789244,
277 -20048966.104014598,
278 20037508.342789244,
279 20048966.104014598,
280 ),
281 wgsMaxExtent=(-180, -85.06, 180, 85.06),
282 coordinatePrecision=COORDINATE_PRECISION_M,
283)
284"""WGS 84 / Pseudo-Mercator CRS (EPSG:3857)."""
286WEBMERCATOR.bounds = gws.Bounds(crs=WEBMERCATOR, extent=WEBMERCATOR.extent)
288WEBMERCATOR_RADIUS = 6378137
289"""Radius of the web mercator sphere (the WGS84 semi-major axis), metres."""
291METERS_PER_DEGREE = 2 * math.pi * WEBMERCATOR_RADIUS / 360
292"""Metres per degree at the equator; the OGC convention for scale denominators in geographic CRS (WMTS 1.0, 6.1)."""
294WEBMERCATOR_SQUARE = (
295 -math.pi * WEBMERCATOR_RADIUS,
296 -math.pi * WEBMERCATOR_RADIUS,
297 +math.pi * WEBMERCATOR_RADIUS,
298 +math.pi * WEBMERCATOR_RADIUS,
299)
300"""Square web mercator extent that covers the whole world width, in meters."""
303class Error(gws.Error):
304 """CRS error."""
306 pass
309def get(crs_name: Optional[gws.CrsName]) -> Optional[gws.Crs]:
310 """Get the CRS for a given CRS name or SRID.
312 Args:
313 crs_name: CRS name in any supported format, or an SRID.
315 Returns:
316 The CRS object, or ``None`` if the name is empty, cannot be parsed or refers to an unsupported CRS.
317 """
318 if not crs_name:
319 return None
320 crs, err = _get_crs(crs_name)
321 if err:
322 gws.log.warning(err)
323 return crs
326def is_valid(crs_name: Optional[gws.CrsName]) -> bool:
327 """Check if a CRS name or SRID refers to a supported CRS, without logging.
329 Args:
330 crs_name: CRS name in any supported format, or an SRID.
332 Returns:
333 ``True`` if ``get`` would return a CRS.
334 """
335 if not crs_name:
336 return False
337 crs, _ = _get_crs(crs_name)
338 return crs is not None
341def parse(crs_name: gws.CrsName) -> tuple[gws.CrsFormat, Optional[gws.Crs]]:
342 """Parse a CRS name into its format and the CRS itself.
344 Args:
345 crs_name: CRS name in any supported format, or an SRID.
347 Returns:
348 A tuple of the name format and the CRS object. If the name cannot be parsed,
349 ``(CrsFormat.none, None)``. If the CRS is unknown or unsupported, the CRS is ``None``.
350 """
351 fmt, srid = _parse(crs_name)
352 if not fmt:
353 return gws.CrsFormat.none, None
354 crs, err = _get_crs(srid)
355 if err:
356 gws.log.warning(err)
357 return fmt, crs
360def require(crs_name: gws.CrsName) -> gws.Crs:
361 """Get the CRS for a given CRS name or SRID, and fail if there is none.
363 Args:
364 crs_name: CRS name in any supported format, or an SRID.
366 Returns:
367 The CRS object.
369 Raises:
370 ``Error``: If the name cannot be parsed or refers to an unknown or unsupported CRS.
371 """
372 crs, err = _get_crs(crs_name)
373 if not crs:
374 raise Error(err)
375 return crs
378##
381def best_match(crs: gws.Crs, supported_crs: list[gws.Crs]) -> gws.Crs:
382 """Return a CRS from the list that most closely matches the given CRS.
384 If the CRS is in the list, it is returned. Otherwise, for a projected CRS, web mercator
385 or the first projected CRS from the list is preferred, and for a geographic CRS,
386 WGS84 or the first geographic CRS. Failing that, the first CRS from the list is returned,
387 or the given CRS if the list is empty.
389 Args:
390 crs: Target CRS.
391 supported_crs: List of supported CRSs.
393 Returns:
394 A CRS object.
395 """
397 if crs in supported_crs:
398 return crs
400 bst = _best_match(crs, supported_crs)
401 if not bst:
402 bst = supported_crs[0] if supported_crs else crs
403 gws.log.debug(f'CRS: best_crs: using {bst.srid!r} for {crs.srid!r}')
404 return bst
407def _best_match(crs, supported_crs):
408 # @TODO find a projection with less errors
409 # @TODO find a projection with same units
411 if crs.isProjected:
412 # for a projected crs, find webmercator
413 for sup in supported_crs:
414 if sup.srid == WEBMERCATOR.srid:
415 return sup
417 # not found, return the first projected crs
418 for sup in supported_crs:
419 if sup.isProjected:
420 return sup
422 if crs.isGeographic:
423 # for a geographic crs, try wgs first
424 for sup in supported_crs:
425 if sup.srid == WGS84.srid:
426 return sup
428 # not found, return the first geographic crs
429 for sup in supported_crs:
430 if sup.isGeographic:
431 return sup
434##
437def _get_crs(crs_name) -> tuple[Optional[gws.Crs], str]:
438 if crs_name in _obj_cache:
439 return _obj_cache[crs_name]
441 fmt, srid = _parse(crs_name)
442 if not fmt:
443 res = None, f'CRS: cannot parse {crs_name!r}'
444 _obj_cache[crs_name] = res
445 return res
447 if srid in _obj_cache:
448 _obj_cache[crs_name] = _obj_cache[srid]
449 return _obj_cache[srid]
451 res = _load_crs(srid)
452 _obj_cache[crs_name] = _obj_cache[srid] = res
453 return res
456def _load_crs(srid) -> tuple[Optional[gws.Crs], str]:
457 pp = _pyproj_crs_object(srid)
458 if not pp:
459 return None, f'CRS: unknown srid {srid!r}'
461 au = _axis_and_unit(pp)
462 if not au:
463 return None, f'CRS: unsupported srid {srid!r}'
465 axis, uom = au
466 if uom not in (gws.Uom.m, gws.Uom.deg):
467 return None, f'CRS: unsupported unit {uom!r} for {srid!r}'
469 return _make_crs(srid, pp, axis, uom)
472def _pyproj_crs_object(srid) -> Optional[pyproj.CRS]:
473 if srid in _pyproj_cache:
474 return _pyproj_cache[srid]
476 try:
477 pp = pyproj.CRS.from_epsg(srid)
478 except pyproj.exceptions.CRSError:
479 return None
481 _pyproj_cache[srid] = pp
482 return _pyproj_cache[srid]
485def _pyproj_transformer(srid_from, srid_to) -> pyproj.transformer.Transformer:
486 key = srid_from, srid_to
488 if key in _transformer_cache:
489 return _transformer_cache[key]
491 pa = _pyproj_crs_object(srid_from)
492 pb = _pyproj_crs_object(srid_to)
494 _transformer_cache[key] = pyproj.transformer.Transformer.from_crs(pa, pb, always_xy=True)
495 return _transformer_cache[key]
498def _transform_extent_check(ext, srid_from, srid_to):
499 ext_nor = _normalize_extent(ext)
501 if srid_from == WGS84.srid:
502 ext_wgs = ext_nor
503 else:
504 tr_to_wgs = _pyproj_transformer(srid_from, WGS84.srid)
505 ext_wgs = tr_to_wgs.transform_bounds(ext_nor[0], ext_nor[1], ext_nor[2], ext_nor[3])
507 if srid_to == WGS84.srid:
508 return _normalize_extent(ext_wgs)
510 pp = _pyproj_crs_object(srid_to)
511 if not pp:
512 raise Error(f'_transform_extent: unknown {srid_to=}')
514 tr_from_wgs = _pyproj_transformer(WGS84.srid, srid_to)
515 au = pp.area_of_use
517 if au:
518 ext_au = au.bounds
519 if _is_big_extent(ext_wgs) and not _is_big_extent(ext_au):
520 ext_to = _transform_extent_sampled(ext_wgs, tr_from_wgs, au)
521 if ext_to:
522 gws.log.debug(f'transform_extent: {ext=} {srid_from!r}->{srid_to!r}: big extent {ext_to=} ')
523 return _normalize_extent(ext_to)
525 if ext_wgs[2] < ext_au[0] or ext_wgs[0] > ext_au[2] or ext_wgs[3] < ext_au[1] or ext_wgs[1] > ext_au[3]:
526 gws.log.debug(f'transform_extent: {ext=} {srid_from!r}->{srid_to!r}: outside of AoU ')
528 ext_to = tr_from_wgs.transform_bounds(ext_wgs[0], ext_wgs[1], ext_wgs[2], ext_wgs[3])
530 return _normalize_extent(ext_to)
533def _transform_extent_sampled(ext_wgs, tr, au, samples=50):
534 x0, y0, x1, y1 = ext_wgs
535 ax0, ay0, ax1, ay1 = au.bounds
536 mid_lon = (ax0 + ax1) / 2
538 xys = []
540 # 1) Dense grid sampling within the area of use (accurate core extent)
541 for i in range(samples + 1):
542 lon = ax0 + (ax1 - ax0) * i / samples
543 for j in range(samples + 1):
544 lat = ay0 + (ay1 - ay0) * j / samples
545 try:
546 xys.append(tr.transform(lon, lat, errcheck=True))
547 except Exception:
548 pass
550 # 2) Sample the full latitude range along the central meridian of the AoU
551 # This captures the full Y extent for "the world" in the target projection
552 for i in range(samples + 1):
553 lat = y0 + (y1 - y0) * i / samples
554 try:
555 xys.append(tr.transform(mid_lon, lat, errcheck=True))
556 except Exception:
557 pass
559 # 3) Sample several meridians across the full longitude range
560 # to capture the full X spread at various latitudes
561 for i in range(samples + 1):
562 lon = x0 + (x1 - x0) * i / samples
563 for j in range(samples + 1):
564 lat = y0 + (y1 - y0) * j / samples
565 try:
566 xys.append(tr.transform(lon, lat, errcheck=True))
567 except Exception:
568 pass
570 xs = [x for x, y in xys if math.isfinite(x) and math.isfinite(y)]
571 ys = [y for x, y in xys if math.isfinite(x) and math.isfinite(y)]
573 if not xs:
574 return
576 return (min(xs), min(ys), max(xs), max(ys))
579def _is_big_extent(ext_wgs):
580 dx = abs(ext_wgs[2] - ext_wgs[0])
581 dy = abs(ext_wgs[3] - ext_wgs[1])
582 return dx > 350 or dy > 160
585def _transform_extent_direct(ext, srid_from, srid_to):
586 tr = _pyproj_transformer(srid_from, srid_to)
588 ext_nor = _normalize_extent(ext)
590 res = tr.transform_bounds(
591 left=ext_nor[0],
592 bottom=ext_nor[1],
593 right=ext_nor[2],
594 top=ext_nor[3],
595 errcheck=True,
596 )
597 return _normalize_extent(res)
600def _normalize_extent(ext):
601 return (
602 min(ext[0], ext[2]),
603 min(ext[1], ext[3]),
604 max(ext[0], ext[2]),
605 max(ext[1], ext[3]),
606 )
609def _make_crs(srid, pp, axis, uom):
610 crs = Object()
612 crs.srid = srid
614 with warnings.catch_warnings():
615 warnings.simplefilter('ignore')
616 try:
617 crs.proj4text = pp.to_proj4()
618 except pyproj.exceptions.CRSError:
619 return None, f'CRS: cannot convert {srid!r} to proj4'
621 crs.wkt = pp.to_wkt()
623 crs.axis = axis
624 crs.uom = uom
625 crs.coordinatePrecision = COORDINATE_PRECISION_M if uom == gws.Uom.m else COORDINATE_PRECISION_DEG
627 crs.isGeographic = pp.is_geographic
628 crs.isProjected = pp.is_projected
629 crs.isYX = crs.axis == gws.Axis.yx
631 crs.epsg = _unparse(crs.srid, gws.CrsFormat.epsg)
632 crs.urn = _unparse(crs.srid, gws.CrsFormat.urn)
633 crs.urnx = _unparse(crs.srid, gws.CrsFormat.urnx)
634 crs.url = _unparse(crs.srid, gws.CrsFormat.url)
635 crs.uri = _unparse(crs.srid, gws.CrsFormat.uri)
637 # see https://proj.org/schemas/v0.5/projjson.schema.json
638 d = pp.to_json_dict()
640 crs.name = d.get('name') or str(crs.srid)
642 def _datum(x):
643 if 'datum_ensemble' in x:
644 return x['datum_ensemble']['name']
645 if 'datum' in x:
646 return x['datum']['name']
647 return ''
649 def _bbox(d):
650 b = d.get('bbox')
651 if b:
652 # pyproj 3.6
653 return b
654 if d.get('usages'):
655 # pyproj 3.7
656 for u in d['usages']:
657 b = u.get('bbox')
658 if b:
659 return b
661 b = d.get('base_crs')
662 if b:
663 crs.base = b['id']['code']
664 crs.datum = _datum(b)
665 else:
666 crs.base = 0
667 crs.datum = _datum(d)
669 b = _bbox(d)
670 if not b:
671 return None, f'CRS: no bbox for {crs.srid!r}'
673 crs.wgsExtent = (
674 b['west_longitude'],
675 b['south_latitude'],
676 b['east_longitude'],
677 b['north_latitude'],
678 )
679 crs.extent = _transform_extent_check(crs.wgsExtent, WGS84.srid, srid)
680 crs.bounds = gws.Bounds(extent=crs.extent, crs=crs)
682 crs.wgsMaxExtent = _wgs_max_extent(pp, crs.wgsExtent)
684 return crs, ''
687def _wgs_max_extent(pp, wgs_extent):
688 """The datum's area of use, or, for global datums, the own extent widened by its width on each side."""
690 geo = pp.geodetic_crs
691 au = geo.area_of_use if geo else None
692 if au and not _is_big_extent(au.bounds):
693 return _normalize_extent(au.bounds)
695 x0, y0, x1, y1 = wgs_extent
696 w = x1 - x0
697 return max(x0 - w, -180), y0, min(x1 + w, 180), y1
700_AXES_AND_UNITS = {
701 'Easting/metre,Northing/metre': (gws.Axis.xy, gws.Uom.m),
702 'Northing/metre,Easting/metre': (gws.Axis.yx, gws.Uom.m),
703 'Geodetic latitude/degree,Geodetic longitude/degree': (gws.Axis.yx, gws.Uom.deg),
704 'Geodetic longitude/degree,Geodetic latitude/degree': (gws.Axis.xy, gws.Uom.deg),
705 'Easting/US survey foot,Northing/US survey foot': (gws.Axis.xy, gws.Uom.us_ft),
706 'Easting/foot,Northing/foot': (gws.Axis.xy, gws.Uom.ft),
707}
710def _axis_and_unit(pp):
711 ax = []
712 for a in pp.axis_info:
713 ax.append(a.name + '/' + a.unit_name)
714 return _AXES_AND_UNITS.get(','.join(ax))
717##
719"""
720Projections can be referenced by:
722 - int/numeric SRID: 4326
723 - EPSG Code: EPSG:4326
724 - OGC HTTP URL: http://www.opengis.net/gml/srs/epsg.xml#4326
725 - OGC Experimental URN: urn:x-ogc:def:crs:EPSG:4326
726 - OGC URN: urn:ogc:def:crs:EPSG::4326
727 - OGC HTTP URI: http://www.opengis.net/def/crs/EPSG/0/4326
729# https://docs.geoserver.org/stable/en/user/services/wfs/webadmin.html#gml
730"""
732_WRITE_FORMATS = {
733 gws.CrsFormat.srid: '{:d}',
734 gws.CrsFormat.epsg: 'EPSG:{:d}',
735 gws.CrsFormat.url: 'http://www.opengis.net/gml/srs/epsg.xml#{:d}',
736 gws.CrsFormat.uri: 'http://www.opengis.net/def/crs/epsg/0/{:d}',
737 gws.CrsFormat.urnx: 'urn:x-ogc:def:crs:EPSG:{:d}',
738 gws.CrsFormat.urn: 'urn:ogc:def:crs:EPSG::{:d}',
739}
741_PARSE_FORMATS = {
742 gws.CrsFormat.srid: r'^(\d+)$',
743 gws.CrsFormat.epsg: r'^epsg:(\d+)$',
744 gws.CrsFormat.url: r'^http://www.opengis.net/gml/srs/epsg.xml#(\d+)$',
745 gws.CrsFormat.uri: r'http://www.opengis.net/def/crs/epsg/0/(\d+)$',
746 gws.CrsFormat.urnx: r'^urn:x-ogc:def:crs:epsg:(\d+)$',
747 gws.CrsFormat.urn: r'^urn:ogc:def:crs:epsg:[0-9.]*:(\d+)$',
748}
750# @TODO
752_aliases = {
753 'crs:84': 4326,
754 'crs84': 4326,
755 'urn:ogc:def:crs:ogc:1.3:crs84': 'urn:ogc:def:crs:epsg::4326',
756 'wgs84': 4326,
757 'epsg:900913': 3857,
758 'epsg:102100': 3857,
759 'epsg:102113': 3857,
760}
762# https://docs.geoserver.org/latest/en/user/services/wfs/axis_order.html
763# EPSG:4326 longitude/latitude
764# http://www.opengis.net/gml/srs/epsg.xml#xxxx longitude/latitude
765# urn:x-ogc:def:crs:EPSG:xxxx latitude/longitude
766# urn:ogc:def:crs:EPSG::4326 latitude/longitude
768_AXIS_FOR_FORMAT = {
769 gws.CrsFormat.srid: gws.Axis.xy,
770 gws.CrsFormat.epsg: gws.Axis.xy,
771 gws.CrsFormat.url: gws.Axis.xy,
772 gws.CrsFormat.uri: gws.Axis.xy,
773 gws.CrsFormat.urnx: gws.Axis.yx,
774 gws.CrsFormat.urn: gws.Axis.yx,
775}
778def _parse(crs_name):
779 if isinstance(crs_name, int):
780 return gws.CrsFormat.epsg, crs_name
782 if isinstance(crs_name, bytes):
783 crs_name = crs_name.decode('ascii').lower()
785 if isinstance(crs_name, str):
786 crs_name = crs_name.lower()
788 if crs_name in {'crs84', 'crs:84'}:
789 return gws.CrsFormat.crs, 4326
791 if crs_name in _aliases:
792 crs_name = _aliases[crs_name]
793 if isinstance(crs_name, int):
794 return gws.CrsFormat.epsg, int(crs_name)
796 for fmt, r in _PARSE_FORMATS.items():
797 m = re.match(r, crs_name)
798 if m:
799 return fmt, int(m.group(1))
801 return None, 0
804def _unparse(srid, fmt):
805 return _WRITE_FORMATS[fmt].format(srid)
808##
811_obj_cache: dict = {
812 WGS84.srid: (WGS84, ''),
813 WEBMERCATOR.url: (WEBMERCATOR, ''),
814}
816_pyproj_cache: dict = {}
818_transformer_cache: dict = {}