Coverage for gws-app/gws/lib/crs/__init__.py: 90%
321 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1from typing import Optional
3import math
4import re
5import warnings
7import pyproj.crs
8import pyproj.exceptions
9import pyproj.transformer
11import gws
14##
17class Object(gws.Crs):
18 def __init__(self, **kwargs):
19 vars(self).update(kwargs)
21 # crs objects with the same srid must be equal
22 # (despite caching, they can be different due to pickling)
24 def __hash__(self):
25 return self.srid
27 def __eq__(self, other):
28 return isinstance(other, Object) and other.srid == self.srid
30 def __repr__(self):
31 return f'<crs:{self.srid}>'
33 def axis_for_format(self, fmt):
34 if not self.isYX:
35 return self.axis
36 return _AXIS_FOR_FORMAT.get(fmt, self.axis)
38 def transform_extent(self, ext, crs_to):
39 if crs_to == self:
40 return ext
41 return _transform_extent_check(ext, self.srid, crs_to.srid)
43 def transformer(self, crs_to):
44 tr = _pyproj_transformer(self.srid, crs_to.srid)
45 return tr.transform
47 def extent_size_in_meters(self, extent):
48 x0, y0, x1, y1 = extent
50 if self.isProjected:
51 if self.uom != gws.Uom.m:
52 # @TODO support non-meter crs
53 raise Error(f'unsupported unit: {self.uom}')
54 return abs(x1 - x0), abs(y1 - y0)
56 geod = pyproj.Geod(ellps='WGS84')
58 mid_lat = (y0 + y1) / 2
59 _, _, w = geod.inv(x0, mid_lat, x1, mid_lat)
60 mid_lon = (x0 + x1) / 2
61 _, _, h = geod.inv(mid_lon, y0, mid_lon, y1)
63 return w, h
65 def point_offset_in_meters(self, xy, dist, az):
66 x, y = xy
68 if self.isProjected:
69 if self.uom != gws.Uom.m:
70 # @TODO support non-meter crs
71 raise Error(f'unsupported unit: {self.uom}')
73 if az == 0:
74 return x, y + dist
75 if az == 90:
76 return x + dist, y
77 if az == 180:
78 return x, y - dist
79 if az == 270:
80 return x - dist, y
82 az_rad = math.radians(90 - az)
83 return (
84 x + dist * math.cos(az_rad),
85 y + dist * math.sin(az_rad),
86 )
88 geod = pyproj.Geod(ellps='WGS84')
89 x, y, _ = geod.fwd(x, y, dist=dist, az=az)
90 return x, y
92 def to_string(self, fmt=None):
93 fmt = fmt or gws.CrsFormat.epsg
94 if fmt == gws.CrsFormat.srid:
95 return str(self.srid)
96 return getattr(self, str(fmt).lower())
98 def to_geojson(self):
99 # https://geojson.org/geojson-spec#named-crs
100 return {
101 'type': 'name',
102 'properties': {
103 'name': self.urn,
104 },
105 }
108#
111def qgis_extent_width(extent: gws.Extent) -> float:
112 # straight port from QGIS/src/core/qgsscalecalculator.cpp QgsScaleCalculator::calculateGeographicDistance
113 x0, y0, x1, y1 = extent
115 lat = (y0 + y1) * 0.5
116 RADS = (4.0 * math.atan(1.0)) / 180.0
117 a = math.pow(math.cos(lat * RADS), 2)
118 c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
119 RA = 6378000
120 E = 0.0810820288
121 radius = RA * (1.0 - E * E) / math.pow(1.0 - E * E * math.sin(lat * RADS) * math.sin(lat * RADS), 1.5)
122 return (x1 - x0) / 180.0 * radius * c
125#
127# enough precision to represent 1cm
128COORDINATE_PRECISION_DEG = 7
129COORDINATE_PRECISION_M = 2
131WGS84: gws.Crs = Object(
132 srid=4326,
133 proj4text='+proj=longlat +datum=WGS84 +no_defs +type=crs',
134 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]]',
135 axis=gws.Axis.yx,
136 uom=gws.Uom.deg,
137 isGeographic=True,
138 isProjected=False,
139 isYX=True,
140 epsg='EPSG:4326',
141 urn='urn:ogc:def:crs:EPSG::4326',
142 urnx='urn:x-ogc:def:crs:EPSG:4326',
143 url='http://www.opengis.net/gml/srs/epsg.xml#4326',
144 uri='http://www.opengis.net/def/crs/epsg/0/4326',
145 name='WGS 84',
146 base=0,
147 datum='World Geodetic System 1984 ensemble',
148 wgsExtent=(-180, -90, 180, 90),
149 extent=(-180, -90, 180, 90),
150 coordinatePrecision=COORDINATE_PRECISION_DEG,
151)
153WGS84.bounds = gws.Bounds(crs=WGS84, extent=WGS84.extent)
155WEBMERCATOR: gws.Crs = Object(
156 srid=3857,
157 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',
158 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]]',
159 axis=gws.Axis.xy,
160 uom=gws.Uom.m,
161 isGeographic=False,
162 isProjected=True,
163 isYX=False,
164 epsg='EPSG:3857',
165 urn='urn:ogc:def:crs:EPSG::3857',
166 urnx='urn:x-ogc:def:crs:EPSG:3857',
167 url='http://www.opengis.net/gml/srs/epsg.xml#3857',
168 uri='http://www.opengis.net/def/crs/epsg/0/3857',
169 name='WGS 84 / Pseudo-Mercator',
170 base=4326,
171 datum='World Geodetic System 1984 ensemble',
172 wgsExtent=(-180, -85.06, 180, 85.06),
173 extent=(
174 -20037508.342789244,
175 -20048966.104014598,
176 20037508.342789244,
177 20048966.104014598,
178 ),
179 coordinatePrecision=COORDINATE_PRECISION_M,
180)
182WEBMERCATOR.bounds = gws.Bounds(crs=WEBMERCATOR, extent=WEBMERCATOR.extent)
184WEBMERCATOR_RADIUS = 6378137
185WEBMERCATOR_SQUARE = (
186 -math.pi * WEBMERCATOR_RADIUS,
187 -math.pi * WEBMERCATOR_RADIUS,
188 +math.pi * WEBMERCATOR_RADIUS,
189 +math.pi * WEBMERCATOR_RADIUS,
190)
193class Error(gws.Error):
194 pass
197def get(crs_name: Optional[gws.CrsName]) -> Optional[gws.Crs]:
198 """Returns the CRS for a given CRS-code or SRID."""
199 if not crs_name:
200 return None
201 return _get_crs(crs_name)
204def parse(crs_name: gws.CrsName) -> tuple[gws.CrsFormat, Optional[gws.Crs]]:
205 """Parses a CRS to a tuple of CRS-format and the CRS itself."""
206 fmt, srid = _parse(crs_name)
207 if not fmt:
208 return gws.CrsFormat.none, None
209 return fmt, _get_crs(srid)
212def require(crs_name: gws.CrsName) -> gws.Crs:
213 """Raises an error if no correct CRS is given."""
214 crs = _get_crs(crs_name)
215 if not crs:
216 raise Error(f'invalid CRS {crs_name!r}')
217 return crs
220##
223def best_match(crs: gws.Crs, supported_crs: list[gws.Crs]) -> gws.Crs:
224 """Return a crs from the list that most closely matches the given crs.
226 Args:
227 crs: target CRS
228 supported_crs: CRS list
230 Returns:
231 A CRS object
232 """
234 if crs in supported_crs:
235 return crs
237 bst = _best_match(crs, supported_crs)
238 if not bst:
239 bst = supported_crs[0] if supported_crs else crs
240 gws.log.debug(f'CRS: best_crs: using {bst.srid!r} for {crs.srid!r}')
241 return bst
244def _best_match(crs, supported_crs):
245 # @TODO find a projection with less errors
246 # @TODO find a projection with same units
248 if crs.isProjected:
249 # for a projected crs, find webmercator
250 for sup in supported_crs:
251 if sup.srid == WEBMERCATOR.srid:
252 return sup
254 # not found, return the first projected crs
255 for sup in supported_crs:
256 if sup.isProjected:
257 return sup
259 if crs.isGeographic:
260 # for a geographic crs, try wgs first
261 for sup in supported_crs:
262 if sup.srid == WGS84.srid:
263 return sup
265 # not found, return the first geographic crs
266 for sup in supported_crs:
267 if sup.isGeographic:
268 return sup
271##
274def _get_crs(crs_name):
275 if crs_name in _obj_cache:
276 return _obj_cache[crs_name]
278 fmt, srid = _parse(crs_name)
279 if not fmt:
280 gws.log.warning(f'CRS: cannot parse {crs_name!r}')
281 _obj_cache[crs_name] = None
282 return None
284 if srid in _obj_cache:
285 _obj_cache[crs_name] = _obj_cache[srid]
286 return _obj_cache[srid]
288 obj = _get_new_crs(srid)
289 _obj_cache[crs_name] = _obj_cache[srid] = obj
290 return obj
293def _get_new_crs(srid):
294 pp = _pyproj_crs_object(srid)
295 if not pp:
296 gws.log.warning(f'CRS: unknown srid {srid!r}')
297 return None
299 au = _axis_and_unit(pp)
300 if not au:
301 gws.log.warning(f'CRS: unsupported srid {srid!r}')
302 return None
304 axis, uom = au
305 if uom not in (gws.Uom.m, gws.Uom.deg):
306 gws.log.warning(f'CRS: unsupported unit {uom!r} for {srid!r}')
307 return None
309 return _make_crs(srid, pp, axis, uom)
312def _pyproj_crs_object(srid) -> Optional[pyproj.CRS]:
313 if srid in _pyproj_cache:
314 return _pyproj_cache[srid]
316 try:
317 pp = pyproj.CRS.from_epsg(srid)
318 except pyproj.exceptions.CRSError:
319 return None
321 _pyproj_cache[srid] = pp
322 return _pyproj_cache[srid]
325def _pyproj_transformer(srid_from, srid_to) -> pyproj.transformer.Transformer:
326 key = srid_from, srid_to
328 if key in _transformer_cache:
329 return _transformer_cache[key]
331 pa = _pyproj_crs_object(srid_from)
332 pb = _pyproj_crs_object(srid_to)
334 _transformer_cache[key] = pyproj.transformer.Transformer.from_crs(pa, pb, always_xy=True)
335 return _transformer_cache[key]
338def _transform_extent_check(ext, srid_from, srid_to):
339 ext_nor = _normalize_extent(ext)
341 if srid_from == WGS84.srid:
342 ext_wgs = ext_nor
343 else:
344 tr_to_wgs = _pyproj_transformer(srid_from, WGS84.srid)
345 ext_wgs = tr_to_wgs.transform_bounds(ext_nor[0], ext_nor[1], ext_nor[2], ext_nor[3])
347 if srid_to == WGS84.srid:
348 return _normalize_extent(ext_wgs)
350 pp = _pyproj_crs_object(srid_to)
351 if not pp:
352 raise Error(f'_transform_extent: unknown {srid_to=}')
354 tr_from_wgs = _pyproj_transformer(WGS84.srid, srid_to)
355 au = pp.area_of_use
357 if au:
358 ext_au = au.bounds
359 if _is_big_extent(ext_wgs) and not _is_big_extent(ext_au):
360 ext_to = _transform_extent_sampled(ext_wgs, tr_from_wgs, au)
361 if ext_to:
362 gws.log.debug(f'transform_extent: {ext=} {srid_from!r}->{srid_to!r}: big extent {ext_to=} ')
363 return _normalize_extent(ext_to)
365 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]:
366 gws.log.warning(f'transform_extent: {ext=} {srid_from!r}->{srid_to!r}: outside of AoU ')
368 ext_to = tr_from_wgs.transform_bounds(ext_wgs[0], ext_wgs[1], ext_wgs[2], ext_wgs[3])
370 return _normalize_extent(ext_to)
373def _transform_extent_sampled(ext_wgs, tr, au, samples=50):
374 x0, y0, x1, y1 = ext_wgs
375 ax0, ay0, ax1, ay1 = au.bounds
376 mid_lon = (ax0 + ax1) / 2
378 xys = []
380 # 1) Dense grid sampling within the area of use (accurate core extent)
381 for i in range(samples + 1):
382 lon = ax0 + (ax1 - ax0) * i / samples
383 for j in range(samples + 1):
384 lat = ay0 + (ay1 - ay0) * j / samples
385 try:
386 xys.append(tr.transform(lon, lat, errcheck=True))
387 except Exception:
388 pass
390 # 2) Sample the full latitude range along the central meridian of the AoU
391 # This captures the full Y extent for "the world" in the target projection
392 for i in range(samples + 1):
393 lat = y0 + (y1 - y0) * i / samples
394 try:
395 xys.append(tr.transform(mid_lon, lat, errcheck=True))
396 except Exception:
397 pass
399 # 3) Sample several meridians across the full longitude range
400 # to capture the full X spread at various latitudes
401 for i in range(samples + 1):
402 lon = x0 + (x1 - x0) * i / samples
403 for j in range(samples + 1):
404 lat = y0 + (y1 - y0) * j / samples
405 try:
406 xys.append(tr.transform(lon, lat, errcheck=True))
407 except Exception:
408 pass
410 xs = [x for x, y in xys if math.isfinite(x) and math.isfinite(y)]
411 ys = [y for x, y in xys if math.isfinite(x) and math.isfinite(y)]
413 if not xs:
414 return
416 return (min(xs), min(ys), max(xs), max(ys))
419def _is_big_extent(ext_wgs):
420 dx = abs(ext_wgs[2] - ext_wgs[0])
421 dy = abs(ext_wgs[3] - ext_wgs[1])
422 return dx > 350 or dy > 160
425def _transform_extent_direct(ext, srid_from, srid_to):
426 tr = _pyproj_transformer(srid_from, srid_to)
428 ext_nor = _normalize_extent(ext)
430 res = tr.transform_bounds(
431 left=ext_nor[0],
432 bottom=ext_nor[1],
433 right=ext_nor[2],
434 top=ext_nor[3],
435 errcheck=True,
436 )
437 return _normalize_extent(res)
440def _normalize_extent(ext):
441 return (
442 min(ext[0], ext[2]),
443 min(ext[1], ext[3]),
444 max(ext[0], ext[2]),
445 max(ext[1], ext[3]),
446 )
449def _make_crs(srid, pp, axis, uom):
450 crs = Object()
452 crs.srid = srid
454 with warnings.catch_warnings():
455 warnings.simplefilter('ignore')
456 try:
457 crs.proj4text = pp.to_proj4()
458 except pyproj.exceptions.CRSError:
459 gws.log.error(f'CRS: cannot convert {srid!r} to proj4')
460 return None
462 crs.wkt = pp.to_wkt()
464 crs.axis = axis
465 crs.uom = uom
466 crs.coordinatePrecision = COORDINATE_PRECISION_M if uom == gws.Uom.m else COORDINATE_PRECISION_DEG
468 crs.isGeographic = pp.is_geographic
469 crs.isProjected = pp.is_projected
470 crs.isYX = crs.axis == gws.Axis.yx
472 crs.epsg = _unparse(crs.srid, gws.CrsFormat.epsg)
473 crs.urn = _unparse(crs.srid, gws.CrsFormat.urn)
474 crs.urnx = _unparse(crs.srid, gws.CrsFormat.urnx)
475 crs.url = _unparse(crs.srid, gws.CrsFormat.url)
476 crs.uri = _unparse(crs.srid, gws.CrsFormat.uri)
478 # see https://proj.org/schemas/v0.5/projjson.schema.json
479 d = pp.to_json_dict()
481 crs.name = d.get('name') or str(crs.srid)
483 def _datum(x):
484 if 'datum_ensemble' in x:
485 return x['datum_ensemble']['name']
486 if 'datum' in x:
487 return x['datum']['name']
488 return ''
490 def _bbox(d):
491 b = d.get('bbox')
492 if b:
493 # pyproj 3.6
494 return b
495 if d.get('usages'):
496 # pyproj 3.7
497 for u in d['usages']:
498 b = u.get('bbox')
499 if b:
500 return b
502 b = d.get('base_crs')
503 if b:
504 crs.base = b['id']['code']
505 crs.datum = _datum(b)
506 else:
507 crs.base = 0
508 crs.datum = _datum(d)
510 b = _bbox(d)
511 if not b:
512 gws.log.error(f'CRS: no bbox for {crs.srid!r}')
513 return
515 crs.wgsExtent = (
516 b['west_longitude'],
517 b['south_latitude'],
518 b['east_longitude'],
519 b['north_latitude'],
520 )
521 crs.extent = _transform_extent_check(crs.wgsExtent, WGS84.srid, srid)
522 crs.bounds = gws.Bounds(extent=crs.extent, crs=crs)
524 return crs
527_AXES_AND_UNITS = {
528 'Easting/metre,Northing/metre': (gws.Axis.xy, gws.Uom.m),
529 'Northing/metre,Easting/metre': (gws.Axis.yx, gws.Uom.m),
530 'Geodetic latitude/degree,Geodetic longitude/degree': (gws.Axis.yx, gws.Uom.deg),
531 'Geodetic longitude/degree,Geodetic latitude/degree': (gws.Axis.xy, gws.Uom.deg),
532 'Easting/US survey foot,Northing/US survey foot': (gws.Axis.xy, gws.Uom.us_ft),
533 'Easting/foot,Northing/foot': (gws.Axis.xy, gws.Uom.ft),
534}
537def _axis_and_unit(pp):
538 ax = []
539 for a in pp.axis_info:
540 ax.append(a.name + '/' + a.unit_name)
541 return _AXES_AND_UNITS.get(','.join(ax))
544##
546"""
547Projections can be referenced by:
549 - int/numeric SRID: 4326
550 - EPSG Code: EPSG:4326
551 - OGC HTTP URL: http://www.opengis.net/gml/srs/epsg.xml#4326
552 - OGC Experimental URN: urn:x-ogc:def:crs:EPSG:4326
553 - OGC URN: urn:ogc:def:crs:EPSG::4326
554 - OGC HTTP URI: http://www.opengis.net/def/crs/EPSG/0/4326
556# https://docs.geoserver.org/stable/en/user/services/wfs/webadmin.html#gml
557"""
559_WRITE_FORMATS = {
560 gws.CrsFormat.srid: '{:d}',
561 gws.CrsFormat.epsg: 'EPSG:{:d}',
562 gws.CrsFormat.url: 'http://www.opengis.net/gml/srs/epsg.xml#{:d}',
563 gws.CrsFormat.uri: 'http://www.opengis.net/def/crs/epsg/0/{:d}',
564 gws.CrsFormat.urnx: 'urn:x-ogc:def:crs:EPSG:{:d}',
565 gws.CrsFormat.urn: 'urn:ogc:def:crs:EPSG::{:d}',
566}
568_PARSE_FORMATS = {
569 gws.CrsFormat.srid: r'^(\d+)$',
570 gws.CrsFormat.epsg: r'^epsg:(\d+)$',
571 gws.CrsFormat.url: r'^http://www.opengis.net/gml/srs/epsg.xml#(\d+)$',
572 gws.CrsFormat.uri: r'http://www.opengis.net/def/crs/epsg/0/(\d+)$',
573 gws.CrsFormat.urnx: r'^urn:x-ogc:def:crs:epsg:(\d+)$',
574 gws.CrsFormat.urn: r'^urn:ogc:def:crs:epsg:[0-9.]*:(\d+)$',
575}
577# @TODO
579_aliases = {
580 'crs:84': 4326,
581 'crs84': 4326,
582 'urn:ogc:def:crs:ogc:1.3:crs84': 'urn:ogc:def:crs:epsg::4326',
583 'wgs84': 4326,
584 'epsg:900913': 3857,
585 'epsg:102100': 3857,
586 'epsg:102113': 3857,
587}
589# https://docs.geoserver.org/latest/en/user/services/wfs/axis_order.html
590# EPSG:4326 longitude/latitude
591# http://www.opengis.net/gml/srs/epsg.xml#xxxx longitude/latitude
592# urn:x-ogc:def:crs:EPSG:xxxx latitude/longitude
593# urn:ogc:def:crs:EPSG::4326 latitude/longitude
595_AXIS_FOR_FORMAT = {
596 gws.CrsFormat.srid: gws.Axis.xy,
597 gws.CrsFormat.epsg: gws.Axis.xy,
598 gws.CrsFormat.url: gws.Axis.xy,
599 gws.CrsFormat.uri: gws.Axis.xy,
600 gws.CrsFormat.urnx: gws.Axis.yx,
601 gws.CrsFormat.urn: gws.Axis.yx,
602}
605def _parse(crs_name):
606 if isinstance(crs_name, int):
607 return gws.CrsFormat.epsg, crs_name
609 if isinstance(crs_name, bytes):
610 crs_name = crs_name.decode('ascii').lower()
612 if isinstance(crs_name, str):
613 crs_name = crs_name.lower()
615 if crs_name in {'crs84', 'crs:84'}:
616 return gws.CrsFormat.crs, 4326
618 if crs_name in _aliases:
619 crs_name = _aliases[crs_name]
620 if isinstance(crs_name, int):
621 return gws.CrsFormat.epsg, int(crs_name)
623 for fmt, r in _PARSE_FORMATS.items():
624 m = re.match(r, crs_name)
625 if m:
626 return fmt, int(m.group(1))
628 return None, 0
631def _unparse(srid, fmt):
632 return _WRITE_FORMATS[fmt].format(srid)
635##
638_obj_cache: dict = {
639 WGS84.srid: WGS84,
640 WEBMERCATOR.url: WEBMERCATOR,
641}
643_pyproj_cache: dict = {}
645_transformer_cache: dict = {}