Coverage for pygeodesy/elevations.py: 80%

69 statements  

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

1 

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

3 

4u'''Web-services-based elevations and C{CONUS} geoid heights. 

5 

6Functions to obtain elevations and geoid heights thru web services, 

7for (lat, lon) locations, currently limited to the U{Conterminous 

8US (CONUS)<https://WikiPedia.org/wiki/Contiguous_United_States>}, 

9see also modules L{pygeodesy.geoids} and L{pygeodesy.heights} and 

10U{USGS10mElev.py<https://Gist.GitHub.com/pyRobShrk>}. 

11 

12@see: Module L{pygeodesy.geoids} to get geoid heights from other 

13 sources and for regions other than C{CONUS}. 

14 

15@note: If on B{macOS} an C{SSLCertVerificationError} occurs, like 

16 I{"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: 

17 self "signed certificate in certificate chain ..."}, review 

18 U{this post<https://StackOverflow.com/questions/27835619/ 

19 urllib-and-ssl-certificate-verify-failed-error>} for a remedy. 

20 From a C{Terminal} window run: 

21 C{"/Applications/Python\\ X.Y/Install\\ Certificates.command"} 

22''' 

23 

24from pygeodesy.basics import clips, ub2str, typename 

25from pygeodesy.errors import ParseError, _xkwds_get 

26# from pygeodesy.internals import typename # from .basics 

27from pygeodesy.interns import NN, _AMPERSAND_, _COLONSPACE_, _DMAIN_, \ 

28 _elevation_, _height_, _LCURLY_, _n_a_, \ 

29 _no_, _RCURLY_, _SPACE_ 

30from pygeodesy.lazily import _ALL_LAZY 

31from pygeodesy.named import _NamedTuple 

32from pygeodesy.streprs import fabs, Fmt, fstr, lrstrip 

33from pygeodesy.units import Lat, Lon, Meter, Scalar, Str 

34 

35# from math import fabs # from .karney 

36 

37__all__ = _ALL_LAZY.elevations 

38__version__ = '25.04.14' 

39 

40try: 

41 from urllib2 import urlopen # quote, urlcleanup 

42 from httplib import HTTPException as HTTPError 

43 

44except (ImportError, NameError): # Python 3+ 

45 from urllib.request import urlopen # urlcleanup 

46 # from urllib.parse import quote 

47 from urllib.error import HTTPError 

48 

49_JSON_ = 'JSON' 

50_QUESTION_ = '?' 

51_XML_ = 'XML' 

52 

53try: 

54 from json import loads as _json 

55except ImportError: 

56 

57 from pygeodesy.interns import _COMMA_, _QUOTE2_ 

58 _QUOTE2COLONSPACE_ = _QUOTE2_ + _COLONSPACE_ 

59 

60 def _json(ngs): 

61 '''(INTERNAL) Convert an NGS response in JSON to a C{dict}. 

62 ''' 

63 # b'{"geoidModel": "GEOID12A", 

64 # "station": "UserStation", 

65 # "lat": 37.8816, 

66 # "latDms": "N375253.76000", 

67 # "lon": -121.9142, 

68 # "lonDms": "W1215451.12000", 

69 # "geoidHeight": -31.703, 

70 # "error": 0.064 

71 # }' 

72 # 

73 # or in case of errors: 

74 # 

75 # b'{"error": "No suitable Geoid model found for model 15" 

76 # }' 

77 d = {} 

78 for t in lrstrip(ngs.strip(), lrpairs={_LCURLY_: _RCURLY_}).split(_COMMA_): 

79 t = t.strip() 

80 j = t.strip(_QUOTE2_).split(_QUOTE2COLONSPACE_) 

81 if len(j) != 2: 

82 raise ParseError(json=t) 

83 k, v = j 

84 try: 

85 v = float(v) 

86 except (TypeError, ValueError): 

87 v = Str(ub2str(v.lstrip().lstrip(_QUOTE2_)), name=k) 

88 d[k] = v 

89 return d 

90 

91 

92def _error(fun, lat, lon, e): 

93 '''(INTERNAL) Format an error 

94 ''' 

95 return _COLONSPACE_(Fmt.PAREN(typename(fun), fstr((lat, lon))), e) 

96 

97 

98def _qURL(url, timeout=2, **params): 

99 '''(INTERNAL) Build B{C{url}} query, get and verify response. 

100 ''' 

101 if params: # build url query, don't map(quote, params)! 

102 p = _AMPERSAND_(*(Fmt.EQUAL(p, v) for p, v in params.items() if v)) 

103 if p: 

104 url = NN(url, _QUESTION_, p) 

105 u = urlopen(url, timeout=timeout) # secs 

106 

107 s = u.getcode() 

108 if s != 200: # http.HTTPStatus.OK or http.client.OK 

109 raise HTTPError('code %d: %s' % (s, u.geturl())) 

110 

111 r = u.read() 

112 u.close() 

113 # urlcleanup() 

114 return ub2str(r).strip() 

115 

116 

117def _xml(tag, xml): 

118 '''(INTERNAL) Get a <tag>value</tag> from XML. 

119 ''' 

120 # b'<?xml version="1.0" encoding="utf-8" ?> 

121 # <USGS_Elevation_Point_Query_Service> 

122 # <Elevation_Query x="-121.914200" y="37.881600"> 

123 # <Data_Source>3DEP 1/3 arc-second</Data_Source> 

124 # <Elevation>3851.03</Elevation> 

125 # <Units>Feet</Units> 

126 # </Elevation_Query> 

127 # </USGS_Elevation_Point_Query_Service>' 

128 i = xml.find(Fmt.TAG(tag)) 

129 if i > 0: 

130 i += len(tag) + 2 

131 j = xml.find(Fmt.TAGEND(tag), i) 

132 if j > i: 

133 return Str(xml[i:j].strip(), name=tag) 

134 return _no_(_XML_, Fmt.TAG(tag)) # PYCHOK no cover 

135 

136 

137class Elevation2Tuple(_NamedTuple): # .elevations.py 

138 '''2-Tuple C{(elevation, data_source)} in C{meter} and C{str}. 

139 ''' 

140 _Names_ = (_elevation_, 'data_source') 

141 _Units_ = ( Meter, Str) 

142 

143 

144def elevation2(lat, lon, timeout=2.0): 

145 '''Get the geoid elevation at an C{NAD83} to C{NAVD88} location. 

146 

147 @arg lat: Latitude (C{degrees}). 

148 @arg lon: Longitude (C{degrees}). 

149 @kwarg timeout: Optional, query timeout (seconds). 

150 

151 @return: An L{Elevation2Tuple}C{(elevation, data_source)} 

152 or (C{None, "error"}) in case of errors. 

153 

154 @raise ValueError: Invalid B{C{timeout}}. 

155 

156 @note: The returned C{elevation is None} if B{C{lat}} or B{C{lon}} is 

157 invalid or outside the C{Conterminous US (CONUS)}, if conversion 

158 failed or if the query timed out. The C{"error"} is the C{HTTP-, 

159 IO-, SSL-} or other C{-Error} as a string (C{str}). 

160 

161 @see: U{USGS Elevation Point Query Service<https://apps.NationalMap.gov/epqs/>}, the 

162 U{FAQ<https://www.USGS.gov/faqs/what-are-projection-horizontal-and-vertical- 

163 datum-units-and-resolution-3dep-standard-dems>}, U{geoid.py<https://Gist.GitHub.com/ 

164 pyRobShrk>}, module L{geoids}, classes L{GeoidG2012B}, L{GeoidKarney} and 

165 L{GeoidPGM}. 

166 ''' 

167 try: # alt 'https://NED.USGS.gov/epqs/pqs.php', 'https://epqs.NationalMap.gov/v1' 

168 x = _qURL('https://NationalMap.USGS.gov/epqs/pqs.php', 

169 x=Lon(lon).toStr(prec=6), 

170 y=Lat(lat).toStr(prec=6), 

171 units='Meters', # 'Feet', capitalized 

172 output=_XML_.lower(), # _JSON_, lowercase only 

173 timeout=Scalar(timeout=timeout)) 

174 if x[:6] == '<?xml ': 

175 e = _xml('Elevation', x) 

176 try: 

177 e = float(e) 

178 if fabs(e) < 1e6: 

179 return Elevation2Tuple(e, _xml('Data_Source', x)) 

180 e = 'non-CONUS %.2F' % (e,) 

181 except (TypeError, ValueError): 

182 pass 

183 else: # PYCHOK no cover 

184 e = _no_(_XML_, Fmt.QUOTE2(clips(x, limit=128, white=_SPACE_))) 

185 except Exception as x: # (HTTPError, IOError, TypeError, ValueError) 

186 e = repr(x) 

187 e = _error(elevation2, lat, lon, e) 

188 return Elevation2Tuple(None, e) 

189 

190 

191class GeoidHeight2Tuple(_NamedTuple): # .elevations.py 

192 '''2-Tuple C{(height, model_name)}, geoid C{height} in C{meter} 

193 and C{model_name} as C{str}. 

194 ''' 

195 _Names_ = (_height_, 'model_name') 

196 _Units_ = ( Meter, Str) 

197 

198 

199def geoidHeight2(lat, lon, model=0, timeout=2.0): 

200 '''Get the C{NAVD88} geoid height at an C{NAD83} location. 

201 

202 @arg lat: Latitude (C{degrees}). 

203 @arg lon: Longitude (C{degrees}). 

204 @kwarg model: Optional, geoid model ID (C{int}). 

205 @kwarg timeout: Optional, query timeout (seconds). 

206 

207 @return: An L{GeoidHeight2Tuple}C{(height, model_name)} 

208 or C{(None, "error"}) in case of errors. 

209 

210 @raise ValueError: Invalid B{C{timeout}}. 

211 

212 @note: The returned C{height is None} if B{C{lat}} or B{C{lon}} is 

213 invalid or outside the C{Conterminous US (CONUS)}, if the 

214 B{C{model}} was invalid, if conversion failed or if the query 

215 timed out. The C{"error"} is the C{HTTP-, IO-, SSL-, URL-} 

216 or other C{-Error} as a string (C{str}). 

217 

218 @see: U{NOAA National Geodetic Survey 

219 <https://www.NGS.NOAA.gov/INFO/geodesy.shtml>}, 

220 U{Geoid<https://www.NGS.NOAA.gov/web_services/geoid.shtml>}, 

221 U{USGS10mElev.py<https://Gist.GitHub.com/pyRobShrk>}, module 

222 L{geoids}, classes L{GeoidG2012B}, L{GeoidKarney} and 

223 L{GeoidPGM}. 

224 ''' 

225 try: 

226 j = _qURL('https://Geodesy.NOAA.gov/api/geoid/ght', 

227 lat=Lat(lat).toStr(prec=6), 

228 lon=Lon(lon).toStr(prec=6), 

229 model=(model if model else NN), 

230 timeout=Scalar(timeout=timeout)) # PYCHOK indent 

231 if j[:1] == _LCURLY_ and j[-1:] == _RCURLY_ and j.find('"error":') > 0: 

232 d, e = _json(j), 'geoidHeight' 

233 if isinstance(_xkwds_get(d, error=_n_a_), float): 

234 h = d.get(e, None) 

235 if h is not None: 

236 m = _xkwds_get(d, geoidModel=_n_a_) 

237 return GeoidHeight2Tuple(h, m) 

238 else: 

239 e = _JSON_ 

240 e = _no_(e, Fmt.QUOTE2(clips(j, limit=256, white=_SPACE_))) 

241 except Exception as x: # (HTTPError, IOError, ParseError, TypeError, ValueError) 

242 e = repr(x) 

243 e = _error(geoidHeight2, lat, lon, e) 

244 return GeoidHeight2Tuple(None, e) 

245 

246 

247if __name__ == _DMAIN_: 

248 

249 from pygeodesy import printf 

250 # <https://WikiPedia.org/wiki/Mount_Diablo> 

251 for f in (elevation2, # (1173.79, '3DEP 1/3 arc-second') 

252 geoidHeight2): # (-31.699, u'GEOID12B') 

253 t = f(37.8816, -121.9142) 

254 printf(_COLONSPACE_(typename(f), t)) 

255 

256# **) MIT License 

257# 

258# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved. 

259# 

260# Permission is hereby granted, free of charge, to any person obtaining a 

261# copy of this software and associated documentation files (the "Software"), 

262# to deal in the Software without restriction, including without limitation 

263# the rights to use, copy, modify, merge, publish, distribute, sublicense, 

264# and/or sell copies of the Software, and to permit persons to whom the 

265# Software is furnished to do so, subject to the following conditions: 

266# 

267# The above copyright notice and this permission notice shall be included 

268# in all copies or substantial portions of the Software. 

269# 

270# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 

271# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

272# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 

273# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 

274# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 

275# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

276# OTHER DEALINGS IN THE SOFTWARE. 

277 

278# % python -m pygeodesy.elevations 

279# elevation2: (1173.79, '3DEP 1/3 arc-second') 

280# geoidHeight2: (-31.703, 'GEOID12B')