Coverage for pygeodesy/webmercator.py: 99%
126 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-04 12:01 -0400
« prev ^ index » next coverage.py v7.6.1, created at 2025-05-04 12:01 -0400
2# -*- coding: utf-8 -*-
4u'''Web Mercator (WM) projection.
6Classes L{Wm} and L{WebMercatorError} and functions L{parseWM} and L{toWm}.
8Pure Python implementation of a U{Web Mercator<https://WikiPedia.org/wiki/Web_Mercator>}
9(aka I{Pseudo-Mercator}) class and conversion functions for spherical and near-spherical
10earth models.
12@see: U{Google Maps / Bing Maps Spherical Mercator Projection
13<https://AlastairA.WordPress.com/2011/01/23/the-google-maps-bing-maps-spherical-mercator-projection>},
14U{Geomatics Guidance Note 7, part 2<https://www.IOGP.org/wp-content/uploads/2019/09/373-07-02.pdf>}
15and U{Implementation Practice Web Mercator Map Projection<https://Web.Archive.org/web/20141009142830/
16http://earth-info.nga.mil/GandG/wgs84/web_mercator/(U)%20NGA_SIG_0011_1.0.0_WEBMERC.pdf>}.
17'''
18# make sure int/int division yields float quotient, see .basics
19from __future__ import division as _; del _ # PYCHOK semicolon
21from pygeodesy.basics import _isin, _splituple, _xinstanceof, typename
22from pygeodesy.constants import PI_2, R_MA, _2_0
23from pygeodesy.datums import Datum, _spherical_datum
24from pygeodesy.dms import clipDegrees, parseDMS2
25from pygeodesy.errors import _parseX, _ValueError, _xattr, _xkwds, _xkwds_pop2
26# from pygeodesy.internals import typename # from .basics
27from pygeodesy.interns import NN, _COMMASPACE_, _datum_, _earth_, _easting_, \
28 _northing_, _radius_, _SPACE_, _x_, _y_
29# from pygeodesy.lazily import _ALL_LAZY from .named
30from pygeodesy.named import _name2__, _NamedBase, _NamedTuple, _ALL_LAZY
31from pygeodesy.namedTuples import LatLon2Tuple, LatLonDatum3Tuple, PhiLam2Tuple
32from pygeodesy.props import deprecated_method, Property_RO
33from pygeodesy.streprs import Fmt, strs, _xzipairs
34from pygeodesy.units import Easting, _isRadius, Lat, Northing, Radius
35from pygeodesy.utily import degrees90, degrees180
37from math import atan, atanh, exp, radians, sin, tanh
39__all__ = _ALL_LAZY.webmercator
40__version__ = '25.04.14'
42# _FalseEasting = 0 # false Easting (C{meter})
43# _FalseNorthing = 0 # false Northing (C{meter})
44_LatLimit = Lat(limit=85.051129) # latitudinal limit (C{degrees})
45# _LonOrigin = 0 # longitude of natural origin (C{degrees})
48class EasNorRadius3Tuple(_NamedTuple):
49 '''3-Tuple C{(easting, northing, radius)}, all in C{meter}.
50 '''
51 _Names_ = (_easting_, _northing_, _radius_)
52 _Units_ = ( Easting, Northing, Radius)
55class WebMercatorError(_ValueError):
56 '''Web Mercator (WM) parser or L{Wm} issue.
57 '''
58 pass
61class Wm(_NamedBase):
62 '''Web Mercator (WM) coordinate.
63 '''
64 _datum = None # set further below
65 _earths = () # dito
66 _radius = R_MA # earth radius (C{meter})
67 _x = 0 # Easting (C{meter})
68 _y = 0 # Northing (C{meter})
70 def __init__(self, x, y, earth=R_MA, **name_radius):
71 '''New L{Wm} Web Mercator (WM) coordinate.
73 @arg x: Easting from central meridian (C{meter}).
74 @arg y: Northing from equator (C{meter}).
75 @kwarg earth: Earth radius (C{meter}), datum or ellipsoid (L{Datum},
76 L{a_f2Tuple}, L{Ellipsoid} or L{Ellipsoid2}).
77 @kwarg name_radius: Optional C{B{name}=NN} (C{str}) and DEPRECATED
78 keyword argument C{B{radius}=earth}, use B{C{earth}}.
80 @note: WM is strictly defined for spherical and WGS84 ellipsoidal
81 earth models only.
83 @raise WebMercatorError: Invalid B{C{x}}, B{C{y}} or B{C{radius}}.
84 '''
85 self._x = Easting( x=x, Error=WebMercatorError)
86 self._y = Northing(y=y, Error=WebMercatorError)
88 R, name = _xkwds_pop2(name_radius, radius=earth)
89 if name:
90 self.name = name
91 if R not in Wm._earths:
92 self._datum = _datum(R, _radius_ if _radius_ in name_radius else _earth_)
93 self._radius = self.datum.ellipsoid.a
95 @Property_RO
96 def datum(self):
97 '''Get the datum (C{Datum}).
98 '''
99 return self._datum
101 @Property_RO
102 def ellipsoid(self):
103 '''Get the ellipsoid (C{Ellipsoid}).
104 '''
105 return self.datum.ellipsoid
107 @Property_RO
108 def latlon(self):
109 '''Get the lat- and longitude (L{LatLon2Tuple}).
110 '''
111 return self.latlon2()
113 def latlon2(self, datum=None):
114 '''Convert this WM coordinate to a lat- and longitude.
116 @kwarg datum: Optional datum (L{Datum}, L{Ellipsoid},
117 L{Ellipsoid2} or L{a_f2Tuple}) or earth
118 radius (C{meter}), overriding this WM's
119 C{radius} and C{datum}.
121 @return: A L{LatLon2Tuple}C{(lat, lon)}.
123 @note: WM is strictly defined for spherical and WGS84
124 ellipsoidal earth models only.
126 @raise TypeError: Invalid or non-ellipsoidal B{C{datum}}.
128 @see: Method C{toLatLon} for other return types.
129 '''
130 d = self.datum if _isin(datum, None, self.datum, self.radius) else _datum(datum)
131 E = d.ellipsoid
132 R = self.radius
133 x = self.x / R
134 y = atan(exp(self.y / R)) * _2_0 - PI_2
135 if E.es or E.a != R: # strictly, WGS84 only
136 # <https://Web.Archive.org/web/20141009142830/http://earth-info.nga.mil/
137 # GandG/wgs84/web_mercator/(U)%20NGA_SIG_0011_1.0.0_WEBMERC.pdf>
138 y = y / R # /= chokes PyChecker
139 y -= E.es_atanh(tanh(y))
140 y *= E.a
141 x *= E.a / R
143 return LatLon2Tuple(degrees90(y), degrees180(x), name=self.name)
145 def parse(self, strWM, **name):
146 '''Parse a string to a similar L{Wm} instance.
148 @arg strWM: The WM coordinate (C{str}), see function L{parseWM}.
149 @kwarg name: Optional C{B{name}=NN} (C{str}), overriding this name.
151 @return: The similar instance (L{Wm}).
153 @raise WebMercatorError: Invalid B{C{strWM}}.
154 '''
155 return parseWM(strWM, radius=self.radius, Wm=self.classof,
156 name=self._name__(name))
158 @deprecated_method
159 def parseWM(self, strWM, name=NN): # PYCHOK no cover
160 '''DEPRECATED, use method L{Wm.parse}.'''
161 return self.parse(strWM, name=name)
163 @Property_RO
164 def philam(self):
165 '''Get the lat- and longitude ((L{PhiLam2Tuple}).
166 '''
167 return PhiLam2Tuple(*map(radians, self.latlon), name=self.name)
169 @Property_RO
170 def radius(self):
171 '''Get the earth radius (C{meter}).
172 '''
173 return self._radius
175 @deprecated_method
176 def to2ll(self, datum=None): # PYCHOK no cover
177 '''DEPRECATED, use method C{latlon2}.
179 @return: A L{LatLon2Tuple}C{(lat, lon)}.
180 '''
181 return self.latlon2(datum=datum)
183 def toLatLon(self, LatLon=None, datum=None, **LatLon_kwds):
184 '''Convert this WM coordinate to a geodetic point.
186 @kwarg LatLon: Ellipsoidal or sphperical C{LatLon} class to
187 return the geodetic point (C{LatLon}) or C{None}.
188 @kwarg datum: Optional, datum (C{Datum}) overriding this WM's.
189 @kwarg LatLon_kwds: Optional, additional B{C{LatLon}} keyword
190 arguments, ignored if C{B{LatLon} is None}.
192 @return: This WM coordinate as B{C{LatLon}} or if C{B{LatLon}
193 is None} a L{LatLonDatum3Tuple}.
195 @raise TypeError: If B{C{datum}} is invalid or if B{C{LatLon}}
196 and B{C{datum}} are incompatible.
197 '''
198 d = datum or self.datum
199 _xinstanceof(Datum, datum=d)
200 r = self.latlon2(datum=d)
201 r = LatLonDatum3Tuple(r.lat, r.lon, d, name=r.name) if LatLon is None else \
202 LatLon(r.lat, r.lon, **_xkwds(LatLon_kwds, datum=d, name=r.name))
203 return r
205 def toRepr(self, prec=3, fmt=Fmt.SQUARE, sep=_COMMASPACE_, radius=False, **unused): # PYCHOK expected
206 '''Return a string representation of this WM coordinate.
208 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).
209 @kwarg fmt: Enclosing backets format (C{str}).
210 @kwarg sep: Optional separator between name:value pairs (C{str}).
211 @kwarg radius: If C{True}, include the radius (C{bool}) or
212 C{scalar} to override this WM's radius.
214 @return: This WM as "[x:meter, y:meter]" (C{str}) or as "[x:meter,
215 y:meter], radius:meter]" if B{C{radius}} is C{True} or
216 C{scalar}.
218 @raise WebMercatorError: Invalid B{C{radius}}.
219 '''
220 t = self.toStr(prec=prec, sep=None, radius=radius)
221 n = (_x_, _y_, _radius_)[:len(t)]
222 return _xzipairs(n, t, sep=sep, fmt=fmt)
224 def toStr(self, prec=3, fmt=Fmt.F, sep=_SPACE_, radius=False, **unused): # PYCHOK expected
225 '''Return a string representation of this WM coordinate.
227 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).
228 @kwarg fmt: Optional C{float} format (C{letter}).
229 @kwarg sep: Optional separator to join (C{str}) or C{None}
230 to return an unjoined C{tuple} of C{str}s.
231 @kwarg radius: If C{True}, include the radius (C{bool}) or
232 C{scalar} to override this WM's radius.
234 @return: This WM as "meter meter" (C{str}) or as "meter meter
235 radius" if B{C{radius}} is C{True} or C{scalar}.
237 @raise WebMercatorError: Invalid B{C{radius}}.
238 '''
239 fs = self.x, self.y
240 if _isRadius(radius):
241 fs += (radius,)
242 elif radius: # is True:
243 fs += (self.radius,)
244 elif not _isin(radius, None, False):
245 raise WebMercatorError(radius=radius)
246 t = strs(fs, prec=prec)
247 return t if sep is None else sep.join(t)
249 @Property_RO
250 def x(self):
251 '''Get the easting (C{meter}).
252 '''
253 return self._x
255 @Property_RO
256 def y(self):
257 '''Get the northing (C{meter}).
258 '''
259 return self._y
261Wm._datum = _spherical_datum(Wm._radius, name=typename(Wm), raiser=_radius_) # PYCHOK defaults
262Wm._earths = (Wm._radius, Wm._datum, Wm._datum.ellipsoid)
265def _datum(earth, name=_datum_):
266 '''(INTERNAL) Make a datum from an C{earth} radius, datum or ellipsoid.
267 '''
268 if earth in Wm._earths:
269 return Wm._datum
270 try:
271 return _spherical_datum(earth, name=name)
272 except Exception as x:
273 raise WebMercatorError(name, earth, cause=x)
276def parseWM(strWM, radius=R_MA, Wm=Wm, **name):
277 '''Parse a string C{"e n [r]"} representing a WM coordinate,
278 consisting of easting, northing and an optional radius.
280 @arg strWM: A WM coordinate (C{str}).
281 @kwarg radius: Optional earth radius (C{meter}), needed in
282 case B{C{strWM}} doesn't include C{r}.
283 @kwarg Wm: Optional class to return the WM coordinate (L{Wm})
284 or C{None}.
285 @kwarg name: Optional C{B{name}=NN} (C{str}).
287 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an
288 L{EasNorRadius3Tuple}C{(easting, northing, radius)}.
290 @raise WebMercatorError: Invalid B{C{strWM}}.
291 '''
292 def _WM(strWM, radius, Wm, name):
293 w = _splituple(strWM)
295 if len(w) == 2:
296 w += (radius,)
297 elif len(w) != 3:
298 raise ValueError
299 x, y, R = map(float, w)
301 return EasNorRadius3Tuple(x, y, R, **name) if Wm is None else \
302 Wm(x, y, earth=R, **name)
304 return _parseX(_WM, strWM, radius, Wm, name,
305 strWM=strWM, Error=WebMercatorError)
308def toWm(latlon, lon=None, earth=R_MA, Wm=Wm, **name_Wm_kwds_radius):
309 '''Convert a lat-/longitude point to a WM coordinate.
311 @arg latlon: Latitude (C{degrees}) or an (ellipsoidal or spherical)
312 geodetic C{LatLon} point.
313 @kwarg lon: Optional longitude (C{degrees} or C{None}).
314 @kwarg earth: Earth radius (C{meter}), datum or ellipsoid (L{Datum},
315 L{a_f2Tuple}, L{Ellipsoid} or L{Ellipsoid2}), overridden
316 by B{C{latlon}}'s datum if present.
317 @kwarg Wm: Optional class to return the WM coordinate (L{Wm}) or C{None}.
318 @kwarg name_Wm_kwds_radius: Optional C{B{name}=NN} (C{str}), optionally,
319 additional B{C{Wm}} keyword arguments, ignored if C{B{Wm} is
320 None} and DEPRECATED keyword argument C{B{radius}=earth}, use
321 B{C{earth}}.
323 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an
324 L{EasNorRadius3Tuple}C{(easting, northing, radius)}.
326 @raise ValueError: If B{C{earth}} is invalid, if B{C{lon}} value is missing,
327 if B{C{latlon}} is not scalar, or if B{C{latlon}} is beyond
328 the valid WM range and L{rangerrrors<pygeodesy.rangerrors>}
329 is C{True}.
330 '''
331 name, kwds = _name2__(name_Wm_kwds_radius)
332 R, kwds = _xkwds_pop2(kwds, radius=earth)
333 d = _datum(R, _radius_ if _radius_ in name_Wm_kwds_radius else _earth_)
334 try:
335 y, x = latlon.lat, latlon.lon
336 y = clipDegrees(y, _LatLimit)
337 d = _xattr(latlon, datum=d)
338 n = latlon._name__(name)
339 except AttributeError:
340 y, x = parseDMS2(latlon, lon, clipLat=_LatLimit)
341 n = name
342 E = d.ellipsoid
343 R = E.a
344 s = sin(radians(y))
345 y = atanh(s) # == log(tand((90 + lat) / 2)) == log(tanPI_2_2(radians(lat)))
346 if E.es:
347 y -= E.es_atanh(s) # strictly, WGS84 only
348 y *= R
349 x = R * radians(x)
350 r = EasNorRadius3Tuple(x, y, R, name=n) if Wm is None else \
351 Wm(x, y, **_xkwds(kwds, earth=d, name=n))
352 return r
354# **) MIT License
355#
356# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved.
357#
358# Permission is hereby granted, free of charge, to any person obtaining a
359# copy of this software and associated documentation files (the "Software"),
360# to deal in the Software without restriction, including without limitation
361# the rights to use, copy, modify, merge, publish, distribute, sublicense,
362# and/or sell copies of the Software, and to permit persons to whom the
363# Software is furnished to do so, subject to the following conditions:
364#
365# The above copyright notice and this permission notice shall be included
366# in all copies or substantial portions of the Software.
367#
368# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
369# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
370# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
371# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
372# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
373# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
374# OTHER DEALINGS IN THE SOFTWARE.