Coverage for pygeodesy/webmercator.py: 99%

126 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-05-04 12:01 -0400

1 

2# -*- coding: utf-8 -*- 

3 

4u'''Web Mercator (WM) projection. 

5 

6Classes L{Wm} and L{WebMercatorError} and functions L{parseWM} and L{toWm}. 

7 

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. 

11 

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 

20 

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 

36 

37from math import atan, atanh, exp, radians, sin, tanh 

38 

39__all__ = _ALL_LAZY.webmercator 

40__version__ = '25.04.14' 

41 

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}) 

46 

47 

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) 

53 

54 

55class WebMercatorError(_ValueError): 

56 '''Web Mercator (WM) parser or L{Wm} issue. 

57 ''' 

58 pass 

59 

60 

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}) 

69 

70 def __init__(self, x, y, earth=R_MA, **name_radius): 

71 '''New L{Wm} Web Mercator (WM) coordinate. 

72 

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}}. 

79 

80 @note: WM is strictly defined for spherical and WGS84 ellipsoidal 

81 earth models only. 

82 

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) 

87 

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 

94 

95 @Property_RO 

96 def datum(self): 

97 '''Get the datum (C{Datum}). 

98 ''' 

99 return self._datum 

100 

101 @Property_RO 

102 def ellipsoid(self): 

103 '''Get the ellipsoid (C{Ellipsoid}). 

104 ''' 

105 return self.datum.ellipsoid 

106 

107 @Property_RO 

108 def latlon(self): 

109 '''Get the lat- and longitude (L{LatLon2Tuple}). 

110 ''' 

111 return self.latlon2() 

112 

113 def latlon2(self, datum=None): 

114 '''Convert this WM coordinate to a lat- and longitude. 

115 

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}. 

120 

121 @return: A L{LatLon2Tuple}C{(lat, lon)}. 

122 

123 @note: WM is strictly defined for spherical and WGS84 

124 ellipsoidal earth models only. 

125 

126 @raise TypeError: Invalid or non-ellipsoidal B{C{datum}}. 

127 

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 

142 

143 return LatLon2Tuple(degrees90(y), degrees180(x), name=self.name) 

144 

145 def parse(self, strWM, **name): 

146 '''Parse a string to a similar L{Wm} instance. 

147 

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. 

150 

151 @return: The similar instance (L{Wm}). 

152 

153 @raise WebMercatorError: Invalid B{C{strWM}}. 

154 ''' 

155 return parseWM(strWM, radius=self.radius, Wm=self.classof, 

156 name=self._name__(name)) 

157 

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) 

162 

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) 

168 

169 @Property_RO 

170 def radius(self): 

171 '''Get the earth radius (C{meter}). 

172 ''' 

173 return self._radius 

174 

175 @deprecated_method 

176 def to2ll(self, datum=None): # PYCHOK no cover 

177 '''DEPRECATED, use method C{latlon2}. 

178 

179 @return: A L{LatLon2Tuple}C{(lat, lon)}. 

180 ''' 

181 return self.latlon2(datum=datum) 

182 

183 def toLatLon(self, LatLon=None, datum=None, **LatLon_kwds): 

184 '''Convert this WM coordinate to a geodetic point. 

185 

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}. 

191 

192 @return: This WM coordinate as B{C{LatLon}} or if C{B{LatLon} 

193 is None} a L{LatLonDatum3Tuple}. 

194 

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 

204 

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. 

207 

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. 

213 

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}. 

217 

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) 

223 

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. 

226 

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. 

233 

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}. 

236 

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) 

248 

249 @Property_RO 

250 def x(self): 

251 '''Get the easting (C{meter}). 

252 ''' 

253 return self._x 

254 

255 @Property_RO 

256 def y(self): 

257 '''Get the northing (C{meter}). 

258 ''' 

259 return self._y 

260 

261Wm._datum = _spherical_datum(Wm._radius, name=typename(Wm), raiser=_radius_) # PYCHOK defaults 

262Wm._earths = (Wm._radius, Wm._datum, Wm._datum.ellipsoid) 

263 

264 

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) 

274 

275 

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. 

279 

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}). 

286 

287 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an 

288 L{EasNorRadius3Tuple}C{(easting, northing, radius)}. 

289 

290 @raise WebMercatorError: Invalid B{C{strWM}}. 

291 ''' 

292 def _WM(strWM, radius, Wm, name): 

293 w = _splituple(strWM) 

294 

295 if len(w) == 2: 

296 w += (radius,) 

297 elif len(w) != 3: 

298 raise ValueError 

299 x, y, R = map(float, w) 

300 

301 return EasNorRadius3Tuple(x, y, R, **name) if Wm is None else \ 

302 Wm(x, y, earth=R, **name) 

303 

304 return _parseX(_WM, strWM, radius, Wm, name, 

305 strWM=strWM, Error=WebMercatorError) 

306 

307 

308def toWm(latlon, lon=None, earth=R_MA, Wm=Wm, **name_Wm_kwds_radius): 

309 '''Convert a lat-/longitude point to a WM coordinate. 

310 

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}}. 

322 

323 @return: The WM coordinate (B{C{Wm}}) or if C{B{Wm} is None}, an 

324 L{EasNorRadius3Tuple}C{(easting, northing, radius)}. 

325 

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 

353 

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.