Coverage for pygeodesy/ups.py: 97%

1

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

3

4 u'''I{Karney}'s Universal Polar Stereographic (UPS) projection.

5

6 Classes L{Ups} and L{UPSError} and functions L{parseUPS5}, L{toUps8} and L{upsZoneBand5}.

7

8 A pure Python implementation, partially transcoded from I{Karney}'s C++ class U{PolarStereographic

9<https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1PolarStereographic.html>}.

10

11 The U{UPS<https://WikiPedia.org/wiki/Universal_polar_stereographic_coordinate_system>} system is used

12 in conjuction with U{UTM<https://WikiPedia.org/wiki/Universal_Transverse_Mercator_coordinate_system>}

13 for locations on the polar regions of the earth. UPS covers areas south of 79.5°S and north of 83.5°N,

14 slightly overlapping the UTM range from 80°S to 84°N by 30' at each end.

15

16 Env variable C{PYGEODESY_UPS_POLES} determines the UPS zones I{at} latitude 90°S and 90°N. By default,

17 the encoding follows I{Karney}'s and U{Appendix B-3 of DMA TM8358.1<https://Web.Archive.org/web/

18 20161226192038/http://earth-info.nga.mil/GandG/publications/tm8358.1/pdf/TM8358_1.pdf>}, using only

19 zones C{'B'} respectively C{'Z'} and digraph C{'AN'}. If C{PYGEODESY_UPS_POLES} is set to anything

20 other than C{"std"}, zones C{'A'} and C{'Y'} are used for negative, west longitudes I{at} latitude

21 90°S respectively 90°N (for backward compatibility).

22'''

23

24# from pygeodesy.basics import neg as _neg # from .dms

25 from pygeodesy.constants import EPS, EPS0, _EPSmin as _Tol90, _K0_UPS, \

26 isnear90, _0_0, _0_5, _1_0, _2_0

27 from pygeodesy.datums import _ellipsoidal_datum, _WGS84

28 from pygeodesy.dms import degDMS, _neg, parseDMS2

29 from pygeodesy.errors import RangeError, _ValueError, _xkwds_pop2

30 from pygeodesy.fmath import hypot, hypot1, sqrt0

31 from pygeodesy.internals import _envPYGEODESY, _under

32 from pygeodesy.interns import NN, _COMMASPACE_, _inside_, _N_, \

33 _pole_, _range_, _S_, _scale0_, \

34 _SPACE_, _std_, _to_, _UTM_

35# from pygeodesy.lazily import _ALL_LAZY, _ALL_MODS as _MODS # from .named

36 from pygeodesy.named import nameof, _ALL_LAZY, _MODS

37 from pygeodesy.namedTuples import EasNor2Tuple, UtmUps5Tuple, \

38 UtmUps8Tuple, UtmUpsLatLon5Tuple

39 from pygeodesy.props import deprecated_method, property_doc_, \

40 Property_RO, _update_all

41# from pygeodesy.streprs import Fmt # from .utmupsBase

42 from pygeodesy.units import Float, Float_, Meter, Lat

43 from pygeodesy.utily import atan1d, atan2, degrees180, sincos2d

44 from pygeodesy.utmupsBase import Fmt, _LLEB, _hemi, _parseUTMUPS5, _to4lldn, \

45 _to3zBhp, _to3zll, _UPS_BANDS as _Bands, \

46 _UPS_LAT_MAX, _UPS_LAT_MIN, _UPS_ZONE, \

47 _UPS_ZONE_STR, UtmUpsBase

48

49 from math import fabs, radians, tan # as _tan

50

51 __all__ = _ALL_LAZY.ups

52 __version__ = '25.04.14'

53

54 _BZ_UPS = _envPYGEODESY('UPS_POLES', _std_) == _std_

55 _Falsing = Meter(2000e3) # false easting and northing (C{meter})

56 _K1_UPS = Float(_K1_UPS=_1_0) # rescale point scale factor

57

58

59 class UPSError(_ValueError):

60 '''Universal Polar Stereographic (UPS) parse or other L{Ups} issue.

61 '''

62 pass

63

64

65 class Ups(UtmUpsBase):

66 '''Universal Polar Stereographic (UPS) coordinate.

67 '''

68# _band = NN # polar band ('A', 'B', 'Y' or 'Z')

69 _Bands = _Bands # polar Band letters (C{tuple})

70 _Error = UPSError # Error class

71 _pole = NN # UPS projection top/center ('N' or 'S')

72# _scale = None # point scale factor (C{scalar})

73 _scale0 = _K0_UPS # central scale factor (C{scalar})

74

75 def __init__(self, zone=0, pole=_N_, easting=_Falsing, # PYCHOK expected

76 northing=_Falsing, band=NN, datum=_WGS84,

77 falsed=True, gamma=None, scale=None,

78 **name_convergence):

79 '''New L{Ups} UPS coordinate.

80

81 @kwarg zone: UPS zone (C{int}, zero) or zone with/-out I{polar} Band

82 letter (C{str}, '00', '00A', '00B', '00Y' or '00Z').

83 @kwarg pole: Top/center of (stereographic) projection (C{str},

84 C{'N[orth]'} or C{'S[outh]'}).

85 @kwarg easting: Easting, see B{C{falsed}} (C{meter}).

86 @kwarg northing: Northing, see B{C{falsed}} (C{meter}).

87 @kwarg band: Optional, I{polar} Band (C{str}, 'A'|'B'|'Y'|'Z').

88 @kwarg datum: Optional, this coordinate's datum (L{Datum},

89 L{Ellipsoid}, L{Ellipsoid2} or L{a_f2Tuple}).

90 @kwarg falsed: If C{True}, both B{C{easting}} and B{C{northing}} are

91 falsed (C{bool}).

92 @kwarg gamma: Optional meridian convergence, bearing off grid North,

93 clockwise from true North to save (C{degrees}) or C{None}.

94 @kwarg scale: Optional grid scale factor to save (C{scalar}) or C{None}.

95 @kwarg name_convergence: Optional C{B{name}=NN} (C{str}) and DEPRECATED

96 keyword argument C{B{convergence}=None}, use B{C{gamma}}.

97

98 @raise TypeError: Invalid B{C{datum}}.

99

100 @raise UPSError: Invalid B{C{zone}}, B{C{pole}}, B{C{easting}},

101 B{C{northing}}, B{C{band}}, B{C{convergence}}

102 or B{C{scale}}.

103 '''

104 if name_convergence:

105 gamma, name = _xkwds_pop2(name_convergence, convergence=gamma)

106 if name:

107 self.name = name

108 try:

109 z, B, self._pole = _to3zBhp(zone, band, hemipole=pole)

110 if z != _UPS_ZONE or (B and (B not in _Bands)):

111 raise ValueError

112 except (TypeError, ValueError) as x:

113 raise UPSError(zone=zone, pole=pole, band=band, cause=x)

114

115 UtmUpsBase.__init__(self, easting, northing, band=B, datum=datum, falsed=falsed,

116 gamma=gamma, scale=scale)

117

118 def __eq__(self, other):

119 return isinstance(other, Ups) and other.zone == self.zone \

120 and other.pole == self.pole \

121 and other.easting == self.easting \

122 and other.northing == self.northing \

123 and other.band == self.band \

124 and other.datum == self.datum

125

126 @property_doc_(''' the I{polar} band.''')

127 def band(self):

128 '''Get the I{polar} band (C{'A'|'B'|'Y'|'Z'}).

129 '''

130 if not self._band:

131 self._toLLEB()

132 return self._band

133

134 @band.setter # PYCHOK setter!

135 def band(self, band):

136 '''Set or reset the I{polar} band letter (C{'A'|'B'|'Y'|'Z'})

137 or C{None} or C{""} to reset.

138

139 @raise TypeError: Invalid B{C{band}}.

140

141 @raise ValueError: Invalid B{C{band}}.

142 '''

143 self._band1(band)

144

145 @Property_RO

146 def falsed2(self):

147 '''Get the easting and northing falsing (L{EasNor2Tuple}C{(easting, northing)}).

148 '''

149 f = _Falsing if self.falsed else 0

150 return EasNor2Tuple(f, f)

151

152 def parse(self, strUPS, **name):

153 '''Parse a string to a similar L{Ups} instance.

154

155 @arg strUPS: The UPS coordinate (C{str}), see function L{parseUPS5}.

156 @kwarg name: Optional C{B{name}=NN} (C{str}), overriding this name.

157

158 @return: The similar instance (L{Ups}).

159

160 @raise UTMError: Invalid B{C{strUPS}}.

161

162 @see: Functions L{parseUTM5} and L{pygeodesy.parseUTMUPS5}.

163 '''

164 return parseUPS5(strUPS, datum=self.datum, Ups=self.classof,

165 name=self._name__(name))

166

167 @deprecated_method

168 def parseUPS(self, strUPS): # PYCHOK no cover

169 '''DEPRECATED, use method L{parse}.'''

170 return self.parse(strUPS)

171

172 @Property_RO

173 def pole(self):

174 '''Get the top/center of (stereographic) projection (C{'N'|'S'} or C{""}).

175 '''

176 return self._pole

177

178 def rescale0(self, lat, scale0=_K0_UPS):

179 '''Set the central scale factor for this UPS projection.

180

181 @arg lat: Northern latitude (C{degrees}).

182 @arg scale0: UPS k0 scale at B{C{lat}} latitude (C{scalar}).

183

184 @raise RangeError: If B{C{lat}} outside the valid range and

185 L{rangerrors<pygeodesy.rangerrors>} is C{True}.

186

187 @raise UPSError: Invalid B{C{scale}}.

188 '''

189 s0 = Float_(scale0=scale0, Error=UPSError, low=EPS) # <= 1.003 or 1.0016?

190 u = toUps8(fabs(Lat(lat)), _0_0, datum=self.datum, Ups=_Ups_K1)

191 k = s0 / u.scale

192 if self.scale0 != k:

193 _update_all(self)

194 self._band = NN # force re-compute

195 self._latlon = self._utm = None

196 self._scale0 = Float(scale0=k)

197

198 def toLatLon(self, LatLon=None, unfalse=True, **LatLon_kwds):

199 '''Convert this UPS coordinate to an (ellipsoidal) geodetic point.

200

201 @kwarg LatLon: Optional, ellipsoidal class to return the

202 geodetic point (C{LatLon}) or C{None}.

203 @kwarg unfalse: Unfalse B{C{easting}} and B{C{northing}}

204 if falsed (C{bool}).

205 @kwarg LatLon_kwds: Optional, additional B{C{LatLon}} keyword

206 arguments, ignored if C{B{LatLon} is None}.

207

208 @return: This UPS coordinate (B{C{LatLon}}) or if C{B{LatLon}

209 is None}, a L{LatLonDatum5Tuple}C{(lat, lon, datum,

210 gamma, scale)}.

211

212 @raise TypeError: If B{C{LatLon}} is not ellipsoidal.

213

214 @raise UPSError: Invalid meridional radius or H-value.

215 '''

216 if self._latlon and self._latlon._toLLEB_args == (unfalse,):

217 return self._latlon5(LatLon)

218 else:

219 self._toLLEB(unfalse=unfalse)

220 return self._latlon5(LatLon, **LatLon_kwds)

221

222 def _toLLEB(self, unfalse=True): # PYCHOK signature

223 '''(INTERNAL) Compute (ellipsoidal) lat- and longitude.

224 '''

225 E = self.datum.ellipsoid # XXX vs LatLon.datum.ellipsoid

226

227 x, y = self.eastingnorthing2(falsed=not unfalse)

228

229 r = hypot(x, y)

230 t = (r * E.es_c / (self.scale0 * E.a * _2_0)) if r > 0 else EPS0

231 t = E.es_tauf(_0_5 / t - _0_5 * t)

232 a = atan1d(t)

233 if self._pole == _N_:

234 b, g = atan2(x, -y), 1

235 else:

236 b, g, a = atan2(x, y), -1, _neg(a)

237 ll = _LLEB(a, degrees180(b), datum=self._datum, name=self.name)

238

239 k = _scale(E, r, t) if r > 0 else self.scale0

240 self._latlon5args(ll, g * b, k, _toBand, unfalse)

241

242 def toRepr(self, prec=0, fmt=Fmt.SQUARE, sep=_COMMASPACE_, B=False, cs=False, **unused): # PYCHOK expected

243 '''Return a string representation of this UPS coordinate.

244

245 Note that UPS coordinates are rounded, not truncated (unlike

246 MGRS grid references).

247

248 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).

249 @kwarg fmt: Enclosing backets format (C{str}).

250 @kwarg sep: Optional separator between name:value pairs (C{str}).

251 @kwarg B: Optionally, include polar band letter (C{bool}).

252 @kwarg cs: Optionally, include gamma meridian convergence and

253 point scale factor (C{bool} or non-zero C{int} to

254 specify the precison like B{C{prec}}).

255

256 @return: This UPS as a string with C{00[Band] pole, easting,

257 northing, [convergence, scale]} as C{"[Z:00[Band],

258 P:N|S, E:meter, N:meter]"} plus C{", C:DMS, S:float"}

259 if C{B{cs} is True}, where C{[Band]} is present and

260 C{'A'|'B'|'Y'|'Z'} only if C{B{B} is True} and

261 convergence C{DMS} is in I{either} degrees, minutes

262 I{or} seconds (C{str}).

263

264 @note: Pseudo zone zero (C{"00"}) for UPS follows I{Karney}'s U{zone UPS

265 <https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1UTMUPS.html>}.

266 '''

267 return self._toRepr(fmt, B, cs, prec, sep)

268

269 def toStr(self, prec=0, sep=_SPACE_, B=False, cs=False): # PYCHOK expected

270 '''Return a string representation of this UPS coordinate.

271

272 Note that UPS coordinates are rounded, not truncated (unlike

273 MGRS grid references).

274

275 @kwarg prec: Number of (decimal) digits, unstripped (C{int}).

276 @kwarg sep: Optional separator to join (C{str}) or C{None}

277 to return an unjoined C{tuple} of C{str}s.

278 @kwarg B: Optionally, include and polar band letter (C{bool}).

279 @kwarg cs: Optionally, include gamma meridian convergence and

280 point scale factor (C{bool} or non-zero C{int} to

281 specify the precison like B{C{prec}}).

282

283 @return: This UPS as a string with C{00[Band] pole, easting,

284 northing, [convergence, scale]} as C{"00[B] N|S

285 meter meter"} plus C{" DMS float"} if B{C{cs}} is C{True},

286 where C{[Band]} is present and C{'A'|'B'|'Y'|'Z'} only

287 if B{C{B}} is C{True} and convergence C{DMS} is in

288 I{either} degrees, minutes I{or} seconds (C{str}).

289

290 @note: Zone zero (C{"00"}) for UPS follows I{Karney}'s U{zone UPS

291 <https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1UTMUPS.html>}.

292 '''

293 return self._toStr(self.pole, B, cs, prec, sep) # PYCHOK pole

294

295 def toUps(self, pole=NN, **unused):

296 '''Duplicate this UPS coordinate.

297

298 @kwarg pole: Optional top/center of the UPS projection,

299 (C{str}, 'N[orth]'|'S[outh]').

300

301 @return: A copy of this UPS coordinate (L{Ups}).

302

303 @raise UPSError: Invalid B{C{pole}} or attempt to transfer

304 the projection top/center.

305 '''

306 if self.pole == pole or not pole:

307 return self.copy()

308 t = _SPACE_(_pole_, repr(self.pole), _to_, repr(pole))

309 raise UPSError('no transfer', txt=t)

310

311 def toUtm(self, zone, falsed=True, **unused):

312 '''Convert this UPS coordinate to a UTM coordinate.

313

314 @arg zone: The UTM zone (C{int}).

315 @kwarg falsed: False both easting and northing (C{bool}).

316

317 @return: The UTM coordinate (L{Utm}).

318 '''

319 u = self._utm

320 if u is None or u.zone != zone or falsed != bool(u.falsed):

321 ll = self.toLatLon(LatLon=None, unfalse=True)

322 utm = _MODS.utm

323 self._utm = u = utm.toUtm8(ll, Utm=utm.Utm, falsed=falsed,

324 name=self.name, zone=zone)

325 return u

326

327 @Property_RO

328 def zone(self):

329 '''Get the polar pseudo zone (C{0}), like I{Karney}'s U{zone UPS<https://

330 GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1UTMUPS.html>}.

331 '''

332 return _UPS_ZONE

333

334

335 class _Ups_K1(Ups):

336 '''(INTERNAL) For method L{Ups.rescale0}.

337 '''

338 _scale0 = _K1_UPS

339

340

341 def parseUPS5(strUPS, datum=_WGS84, Ups=Ups, falsed=True, **name):

342 '''Parse a string representing a UPS coordinate, consisting of

343 C{"[zone][band] pole easting northing"} where B{C{zone}} is

344 pseudo zone C{"00"|"0"|""} and C{band} is C{'A'|'B'|'Y'|'Z'|''}.

345

346 @arg strUPS: A UPS coordinate (C{str}).

347 @kwarg datum: Optional datum to use (L{Datum}).

348 @kwarg Ups: Optional class to return the UPS coordinate (L{Ups})

349 or C{None}.

350 @kwarg falsed: If C{True}, both B{C{easting}} and B{C{northing}}

351 are falsed (C{bool}).

352 @kwarg name: Optional B{C{Ups}} C{B{name}=NN} (C{str}).

353

354 @return: The UPS coordinate (B{C{Ups}}) or if C{B{Ups} is None}, a

355 L{UtmUps5Tuple}C{(zone, hemipole, easting, northing, band)}.

356 The C{hemipole} is the C{'N'|'S'} pole, the UPS projection

357 top/center.

358

359 @raise UPSError: Invalid B{C{strUPS}}.

360 '''

361 z, p, e, n, B = _parseUTMUPS5(strUPS, _UPS_ZONE_STR, Error=UPSError)

362 if z != _UPS_ZONE or (B and B not in _Bands):

363 raise UPSError(strUPS=strUPS, zone=z, band=B)

364

365 r = UtmUps5Tuple(z, p, e, n, B, Error=UPSError, **name) if Ups is None \

366 else Ups(z, p, e, n, band=B, falsed=falsed, datum=datum, **name)

367 return r

368

369

370 def _scale(E, rho, tau):

371 # compute the point scale factor, ala Karney

372 t = hypot1(tau)

373 return Float(scale=(rho / E.a) * t * sqrt0(E.e21 + E.e2 / t**2))

374

375

376 def _toBand(lat, lon): # see utm._toBand

377 '''(INTERNAL) Get the I{polar} Band letter for a (lat, lon).

378 '''

379 return _Bands[(0 if lat < 0 else 2) + (0 if -180 < lon < 0 else 1)]

380

381

382 def toUps8(latlon, lon=None, datum=None, Ups=Ups, pole=NN,

383 falsed=True, strict=True, **name):

384 '''Convert a lat-/longitude point to a UPS coordinate.

385

386 @arg latlon: Latitude (C{degrees}) or an (ellipsoidal) geodetic

387 C{LatLon} point.

388 @kwarg lon: Optional longitude (C{degrees}) or C{None} if

389 B{C{latlon}} is a C{LatLon}.

390 @kwarg datum: Optional datum for this UPS coordinate, overriding

391 B{C{latlon}}'s datum (C{Datum}, L{Ellipsoid},

392 L{Ellipsoid2} or L{a_f2Tuple}).

393 @kwarg Ups: Optional class to return the UPS coordinate (L{Ups})

394 or C{None}.

395 @kwarg pole: Optional top/center of (stereographic) projection

396 (C{str}, C{'N[orth]'} or C{'S[outh]'}).

397 @kwarg falsed: If C{True}, false both easting and northing (C{bool}).

398 @kwarg strict: Restrict B{C{lat}} to UPS ranges (C{bool}).

399 @kwarg name: Optional B{C{Ups}} C{B{name}=NN} (C{str}).

400

401 @return: The UPS coordinate (B{C{Ups}}) or if C{B{Ups} is None}, a

402 L{UtmUps8Tuple}C{(zone, hemipole, easting, northing, band,

403 datum, gamma, scale)} where C{hemipole} is the C{'N'|'S'}

404 pole, the UPS projection top/center.

405

406 @raise RangeError: If B{C{strict}} and B{C{lat}} outside the valid UPS bands

407 or if B{C{lat}} or B{C{lon}} outside the valid range and

408 L{rangerrors<pygeodesy.rangerrors>} is C{True}.

409

410 @raise TypeError: If B{C{latlon}} is not ellipsoidal or if B{C{datum}} is invalid.

411

412 @raise ValueError: If B{C{lon}} value is missing or if B{C{latlon}} is invalid.

413

414 @see: I{Karney}'s C++ class U{UPS

415 <https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1UPS.html>}.

416 '''

417 lat, lon, d, name = _to4lldn(latlon, lon, datum, name)

418 z, B, p, lat, lon = upsZoneBand5(lat, lon, strict=strict) # PYCHOK UtmUpsLatLon5Tuple

419

420 d = _ellipsoidal_datum(d, name=name)

421 E = d.ellipsoid

422

423 p = str(pole or p)[:1].upper()

424 S = p == _S_ # at south[pole]

425

426 a = -lat if S else lat

427 P = isnear90(a, eps90=_Tol90) # at pole

428

429 t = tan(radians(a))

430 T = E.es_taupf(t)

431 r = (hypot1(T) - T) if T < 0 else (_0_0 if P else _1_0 /

432 (hypot1(T) + T))

433

434 k0 = getattr(Ups, _under(_scale0_), _K0_UPS) # Ups is class or None

435 r *= k0 * E.a * _2_0 / E.es_c

436

437 k = k0 if P else _scale(E, r, t)

438 g = lon # [-180, 180) from .upsZoneBand5

439 x, y = sincos2d(g)

440 x *= r

441 y *= r

442 if S:

443 g = _neg(g)

444 else:

445 y = _neg(y)

446

447 if falsed:

448 x += _Falsing

449 y += _Falsing

450

451 n = name or nameof(latlon)

452 if Ups is None:

453 r = UtmUps8Tuple(z, p, x, y, B, d, g, k, Error=UPSError, name=n)

454 else:

455 if z != _UPS_ZONE and not strict:

456 z = _UPS_ZONE # ignore UTM zone

457 r = Ups(z, p, x, y, band=B, datum=d, falsed=falsed,

458 gamma=g, scale=k, name=n)

459 if isinstance(latlon, _LLEB) and d is latlon.datum: # see utm._toXtm8

460 r._latlon5args(latlon, g, k, _toBand, falsed) # XXX weakref(latlon)?

461 else:

462 r._hemisphere = _hemi(lat)

463 if not r._band:

464 r._band = _toBand(lat, lon)

465 return r

466

467

468 def upsZoneBand5(lat, lon, strict=True, **name):

469 '''Return the UTM/UPS zone number, I{polar} Band letter, pole and

470 clipped lat- and longitude for a given location.

471

472 @arg lat: Latitude in degrees (C{scalar} or C{str}).

473 @arg lon: Longitude in degrees (C{scalar} or C{str}).

474 @kwarg strict: Restrict B{C{lat}} to UPS ranges (C{bool}).

475 @kwarg name: Optional B{C{Ups}} C{B{name}=NN} (C{str}).

476

477 @return: A L{UtmUpsLatLon5Tuple}C{(zone, band, hemipole, lat, lon)}

478 where C{hemipole} is the C{'N'|'S'} pole, the UPS projection

479 top/center and C{lon} [-180..180).

480

481 @note: The C{lon} is set to C{0} if B{C{lat}} is C{-90} or C{90}, see env

482 variable C{PYGEODESY_UPS_POLES} in module L{ups<pygeodesy.ups>}.

483

484 @raise RangeError: If B{C{strict} is True} and B{C{lat}} within the UTM but not

485 the UPS range or if B{C{lat}} or B{C{lon}} outside the valid

486 range and L{rangerrors<pygeodesy.rangerrors>} is C{True}.

487

488 @raise ValueError: Invalid B{C{lat}} or B{C{lon}}.

489 '''

490 z, lat, lon = _to3zll(*parseDMS2(lat, lon))

491 if _BZ_UPS and lon < 0 and isnear90(fabs(lat), eps90=_Tol90): # DMA TM8358.1 only ...

492 lon = 0 # ... zones B and Z at 90°S and 90°N, see also GeoConvert

493

494 if lat < _UPS_LAT_MIN: # includes 30' overlap

495 z, B, p = _UPS_ZONE, _toBand(lat, lon), _S_

496

497 elif lat > _UPS_LAT_MAX: # includes 30' overlap

498 z, B, p = _UPS_ZONE, _toBand(lat, lon), _N_

499

500 elif strict:

501 r = _range_(_UPS_LAT_MIN, _UPS_LAT_MAX, prec=1)

502 t = _SPACE_(_inside_, _UTM_, _range_, r)

503 raise RangeError(lat=degDMS(lat), txt=t)

504

505 else:

506 B, p = NN, _hemi(lat)

507 return UtmUpsLatLon5Tuple(z, B, p, lat, lon, Error=UPSError, **name)

508

509# **) MIT License

510#

511# Copyright (C) 2016-2026 -- mrJean1 at Gmail -- All Rights Reserved.

512#

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

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

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

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

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

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

519#

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

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

522#

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

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

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

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

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

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

529# OTHER DEALINGS IN THE SOFTWARE.