Coverage for pygeodesy / ltp.py: 95%

331 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-07-26 22:05 -0400

1 

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

3 

4u'''I{Local Tangent Plane} (LTP) and I{local} cartesian coordinates. 

5 

6I{Local cartesian} and I{local tangent plane} classes L{LocalCartesian}, L{Ltp}, L{LqRD}, 

7L{LocalError}, L{Attitude} and L{Frustum}. 

8 

9@see: U{Local tangent plane coordinates<https://WikiPedia.org/wiki/Local_tangent_plane_coordinates>} 

10 and class L{LocalCartesian}, transcoded from I{Charles Karney}'s C++ classU{LocalCartesian 

11 <https://GeographicLib.SourceForge.io/C++/doc/classGeographicLib_1_1LocalCartesian.html>}. 

12''' 

13# make sure int/int division yields float quotient, see .basics 

14from __future__ import division as _; del _ # noqa: E702 ; 

15 

16from pygeodesy.basics import map1, map2, _xinstanceof, _xsubclassof 

17from pygeodesy.constants import EPS, INT0, _umod_360, _0_0, _0_01, _0_5, _1_0, \ 

18 _2_0, _8_0, _60_0, _90_0, _100_0, _180_0, _3600_0, \ 

19 _N_1_0 # PYCHOK used! 

20from pygeodesy.datums import Datums, _WGS84 

21# from pygeodesy.dms import parseDMS # from .units 

22from pygeodesy.ecef import _EcefBase, EcefKarney, Ecef9Tuple, _llhn4, _xyzn4 

23from pygeodesy.errors import _ValueError, _xattr, _xkwds, _xkwds_get, _xkwds_pop2 

24from pygeodesy.fmath import fabs, fdot, fdot_ 

25from pygeodesy.fsums import fsumf_ 

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

27from pygeodesy.interns import _0_, _COMMASPACE_, _ecef_, _height_, _invalid_, \ 

28 _lat0_, _lon0_, _M_, _name_, _too_ 

29# from pygeodesy.lazily import _ALL_LAZY # from vector3d 

30from pygeodesy.ltpTuples import Attitude4Tuple, Footprint5Tuple, Local9Tuple, \ 

31 _XyzLocals4, _XyzLocals5, Xyz4Tuple 

32from pygeodesy.named import _name__, _name2__, _NamedBase 

33from pygeodesy.namedTuples import LatLon3Tuple, LatLon4Tuple, Vector3Tuple, \ 

34 Bounds4Tuple, RD4Tuple # PYCHOK used! 

35from pygeodesy.props import deprecated_property_RO, Property, Property_RO, \ 

36 property_doc_, property_ROver, _update_all 

37from pygeodesy.streprs import Fmt, strs 

38from pygeodesy.units import Bearing, Degrees, _isHeight, Meter, parseDMS 

39from pygeodesy.utily import cotd, _loneg, sincos2d, sincos2d_, tand, tand_, \ 

40 wrap180, wrap360 

41from pygeodesy.vector3d import _ALL_LAZY, Vector3d 

42 

43# from math import fabs, floor as _floor # from .fmath, .fsums 

44 

45__all__ = _ALL_LAZY.ltp 

46__version__ = '26.07.24' 

47 

48_GRS80 = Datums.GRS80 

49_height0_ = _height_ + _0_ 

50_narrow_ = 'narrow' 

51_wide_ = 'wide' 

52# del Datums 

53 

54 

55class Attitude(_NamedBase): 

56 '''The pose of a plane or camera in space. 

57 ''' 

58 _alt = Meter( alt =_0_0) 

59 _roll = Degrees(roll=_0_0) 

60 _tilt = Degrees(tilt=_0_0) 

61 _yaw = Bearing(yaw =_0_0) 

62 

63 def __init__(self, alt_attitude=INT0, tilt=INT0, yaw=INT0, roll=INT0, **name): 

64 '''New L{Attitude}. 

65 

66 @kwarg alt_attitude: Altitude (C{meter}) above earth or a previous attitude 

67 (L{Attitude} or L{Attitude4Tuple}) with the C{B{alt}itude}, 

68 B{C{tilt}}, B{C{yaw}} and B{C{roll}}. 

69 @kwarg tilt: Pitch, elevation from horizontal (C{degrees180}), negative down 

70 (clockwise rotation along and around the x- or East axis), iff 

71 B{C{alt_attitude}} is C{meter}, ignored otherwise. 

72 @kwarg yaw: Bearing, heading (compass C{degrees360}), clockwise from North 

73 (counter-clockwise rotation along and around the z- or Up axis) 

74 iff B{C{alt_attitude}} is C{meter}, ignored otherwise. 

75 @kwarg roll: Roll, bank (C{degrees180}), positive to the right and down 

76 (clockwise rotation along and around the y- or North axis), iff 

77 B{C{alt_attitude}} is C{meter}, ignored otherwise. 

78 @kwarg name: Optional C{B{name}=NN} C{str}). 

79 

80 @raise AttitudeError: Invalid B{C{alt_attitude}}, B{C{tilt}}, B{C{yaw}} or 

81 B{C{roll}}. 

82 

83 @see: U{Principal axes<https://WikiPedia.org/wiki/Aircraft_principal_axes>} and 

84 U{Yaw, pitch, and roll rotations<http://MSL.CS.UIUC.edu/planning/node102.html>}. 

85 ''' 

86 if _isHeight(alt_attitude): 

87 t = Attitude4Tuple(alt_attitude, tilt, yaw, roll) 

88 else: 

89 try: 

90 t = alt_attitude.atyr 

91 except AttributeError: 

92 raise AttitudeError(alt=alt_attitude, tilt=tilt, yaw=yaw, rol=roll) 

93 for n, v in t.items(): 

94 if v: 

95 setattr(self, n, v) 

96 n = _name__(name, _or_nameof=t) 

97 if n: 

98 self.name = n 

99 

100 @property_doc_(' altitude above earth in C{meter}.') 

101 def alt(self): 

102 return self._alt 

103 

104 @alt.setter # PYCHOK setter! 

105 def alt(self, alt): # PYCHOK no cover 

106 a = Meter(alt=alt, Error=AttitudeError) 

107 if self._alt != a: 

108 _update_all(self) 

109 self._alt = a 

110 

111 altitude = alt 

112 

113 @Property_RO 

114 def atyr(self): 

115 '''Return this attitude's alt[itude], tilt, yaw and roll as an L{Attitude4Tuple}. 

116 ''' 

117 return Attitude4Tuple(self.alt, self.tilt, self.yaw, self.roll, name=self.name) 

118 

119 @Property_RO 

120 def matrix(self): 

121 '''Get the 3x3 rotation matrix C{R(yaw)·R(tilt)·R(roll)}, aka I{ZYX} (C{float}, row-order). 

122 

123 @see: Matrix M of case 10 in U{Appendix A 

124 <https://ntrs.NASA.gov/api/citations/19770019231/downloads/19770019231.pdf>}. 

125 ''' 

126 # to follow the definitions of rotation angles alpha, beta and gamma: 

127 # negate yaw since yaw is counter-clockwise around the z-axis, swap 

128 # tilt and roll since tilt is around the x- and roll around the y-axis 

129 sa, ca, sb, cb, sg, cg = sincos2d_(-self.yaw, self.roll, self.tilt) 

130 return ((ca * cb, fdot_(ca, sb * sg, -sa, cg), fdot_(ca, sb * cg, sa, sg)), 

131 (sa * cb, fdot_(sa, sb * sg, ca, cg), fdot_(sa, sb * cg, -ca, sg)), 

132 ( -sb, cb * sg, cb * cg)) 

133 

134 @property_doc_(' roll/bank in C{degrees180}, positive to the right and down.') 

135 def roll(self): 

136 return self._roll 

137 

138 @roll.setter # PYCHOK setter! 

139 def roll(self, roll): 

140 r = Degrees(roll=roll, wrap=wrap180, Error=AttitudeError) 

141 if self._roll != r: 

142 _update_all(self) 

143 self._roll = r 

144 

145 bank = roll 

146 

147 def rotate(self, x_xyz, y=None, z=None, Vector=None, **name_Vector_kwds): 

148 '''Transform a (local) cartesian by this attitude's matrix. 

149 

150 @arg x_xyz: X component of vector (C{scalar}) or (3-D) vector (C{Cartesian}, 

151 L{Vector3d} or L{Vector3Tuple}). 

152 @kwarg y: Y component of vector (C{scalar}), same units as C{scalar} B{C{x}}, 

153 ignored otherwise. 

154 @kwarg z: Z component of vector (C{scalar}), same units as C{sclar} B{C{x}}, 

155 ignored otherwise. 

156 @kwarg Vector: Class to return transformed point (C{Cartesian}, L{Vector3d} 

157 or C{Vector3Tuple}) or C{None}. 

158 @kwarg name_Vector_kwds: Optional C{B{name}=NN} (C{str}) and optionally, 

159 additional B{C{Vector}} keyword arguments, ignored if C{B{Vector} 

160 is None}. 

161 

162 @return: A named B{C{Vector}} instance or if C{B{Vector} is None}, 

163 a named L{Vector3Tuple}C{(x, y, z)}. 

164 

165 @raise AttitudeError: Invalid B{C{x_xyz}}, B{C{y}} or B{C{z}}. 

166 

167 @raise TypeError: Invalid B{C{Vector}} or B{C{name_Vector_kwds}} item. 

168 

169 @see: U{Yaw, pitch, and roll rotations<http://MSL.CS.UIUC.edu/planning/node102.html>}. 

170 ''' 

171 try: 

172 try: 

173 xyz = map2(float, x_xyz.xyz3) 

174 except AttributeError: 

175 xyz = map1(float, x_xyz, y, z) 

176 except (TypeError, ValueError) as x: 

177 raise AttitudeError(x_xyz=x_xyz, y=y, z=z, cause=x) 

178 

179 x, y, z = (fdot(r, *xyz) for r in self.matrix) 

180 n, kwds = _name2__(name_Vector_kwds, _or_nameof=self) 

181 return Vector3Tuple(x, y, z, name=n) if Vector is None else \ 

182 Vector(x, y, z, name=n, **kwds) 

183 

184 @property_doc_(' tilt/pitch/elevation from horizontal in C{degrees180}, negative down.') 

185 def tilt(self): 

186 return self._tilt 

187 

188 @tilt.setter # PYCHOK setter! 

189 def tilt(self, tilt): 

190 t = Degrees(tilt=tilt, wrap=wrap180, Error=AttitudeError) 

191 if self._tilt != t: 

192 _update_all(self) 

193 self._tilt = t 

194 

195 elevation = pitch = tilt 

196 

197 def toStr(self, prec=6, sep=_COMMASPACE_, **unused): # PYCHOK signature 

198 '''Format this attitude as string. 

199 

200 @kwarg prec: The C{float} precision, number of decimal digits (0..9). 

201 Trailing zero decimals are stripped for B{C{prec}} values 

202 of 1 and above, but kept for negative B{C{prec}} values. 

203 @kwarg sep: Separator to join (C{str}). 

204 

205 @return: This attitude (C{str}). 

206 ''' 

207 return self.atyr.toStr(prec=prec, sep=sep) 

208 

209 @Property_RO 

210 def tyr3d(self): 

211 '''Get this attitude's (3-D) directional vector (L{Vector3d}). 

212 

213 @see: U{Yaw, pitch, and roll rotations<http://MSL.CS.UIUC.edu/planning/node102.html>}. 

214 ''' 

215 def _r2d(r): 

216 return fsumf_(_N_1_0, *r) 

217 

218 return Vector3d(*map(_r2d, self.matrix), name__=tyr3d) 

219 

220 @property_doc_(' yaw/bearing/heading in compass C{degrees360}, clockwise from North.') 

221 def yaw(self): 

222 return self._yaw 

223 

224 @yaw.setter # PYCHOK setter! 

225 def yaw(self, yaw): 

226 y = Bearing(yaw=yaw, Error=AttitudeError) 

227 if self._yaw != y: 

228 _update_all(self) 

229 self._yaw = y 

230 

231 bearing = heading = yaw # azimuth 

232 

233 

234class AttitudeError(_ValueError): 

235 '''An L{Attitude} or L{Attitude4Tuple} issue. 

236 ''' 

237 pass 

238 

239 

240class Frustum(_NamedBase): 

241 '''A rectangular pyramid, typically representing a camera's I{field-of-view} 

242 (fov) and the intersection with (or projection to) a I{local tangent plane}. 

243 

244 @see: U{Viewing frustum<https://WikiPedia.org/wiki/Viewing_frustum>}. 

245 ''' 

246 _h_2 = _0_0 # half hfov in degrees 

247 _ltp = None # local tangent plane 

248 _tan_h_2 = _0_0 # tan(_h_2) 

249 _v_2 = _0_0 # half vfov in degrees 

250 

251 def __init__(self, hfov, vfov, ltp=None, **name): 

252 '''New L{Frustum}. 

253 

254 @arg hfov: Horizontal field-of-view (C{degrees180}). 

255 @arg vfov: Vertical field-of-view (C{degrees180}). 

256 @kwarg ltp: Optional I{local tangent plane} (L{Ltp}). 

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

258 

259 @raise LocalError: Invalid B{C{hfov}} or B{C{vfov}}. 

260 ''' 

261 self._h_2 = h = _fov_2(hfov=hfov) 

262 self._v_2 = _fov_2(vfov=vfov) 

263 

264 self._tan_h_2 = tand(h, hfov_2=h) 

265 

266 if ltp: 

267 self._ltp = _xLtp(ltp) 

268 if name: 

269 self.name # PYCHOK effect 

270 

271 def footprint5(self, alt_attitude, tilt=0, yaw=0, roll=0, z=_0_0, ltp=None, **name): # MCCABE 15 

272 '''Compute the center and corners of the intersection with (or projection 

273 to) the I{local tangent plane} (LTP). 

274 

275 @arg alt_attitude: An altitude (C{meter}) above I{local tangent plane} or 

276 an attitude (L{Attitude} or L{Attitude4Tuple}) with the 

277 C{B{alt}itude}, B{C{tilt}}, B{C{yaw}} and B{C{roll}}. 

278 @kwarg tilt: Pitch, elevation from horizontal (C{degrees}), negative down 

279 (clockwise rotation along and around the x- or East axis) iff 

280 B{C{alt_attitude}} is C{meter}, ignored otherwise. 

281 @kwarg yaw: Bearing, heading (compass C{degrees}), clockwise from North 

282 (counter-clockwise rotation along and around the z- or Up axis) 

283 iff B{C{alt_attitude}} is C{meter}, ignored otherwise. 

284 @kwarg roll: Roll, bank (C{degrees}), positive to the right and down 

285 (clockwise rotation along and around the y- or North axis) iff 

286 B{C{alt_attitude}} is C{meter}, ignored otherwise. 

287 @kwarg z: Optional height of the footprint (C{meter}) above I{local tangent plane}. 

288 @kwarg ltp: The I{local tangent plane} (L{Ltp}), overriding this 

289 frustum's C{ltp}. 

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

291 

292 @return: A L{Footprint5Tuple}C{(center, upperleft, upperight, loweright, 

293 lowerleft)} with the C{center} and 4 corners, each an L{Xyz4Tuple}. 

294 

295 @raise TypeError: Invalid B{C{ltp}}. 

296 

297 @raise UnitError: Invalid B{C{altitude}}, B{C{tilt}}, B{C{roll}} or B{C{z}}. 

298 

299 @raise ValueError: If B{C{altitude}} too low, B{C{z}} too high or B{C{tilt}} 

300 or B{C{roll}} -including B{C{vfov}} respectively B{C{hfov}}- 

301 over the horizon. 

302 

303 @see: U{Principal axes<https://WikiPedia.org/wiki/Aircraft_principal_axes>}. 

304 ''' 

305 def _xy2(a, e, h_2, tan_h_2, r): 

306 # left and right corners, or swapped 

307 if r < EPS: # no roll 

308 r = a * tan_h_2 

309 l = -r # noqa: E741 l is ell 

310 else: # roll 

311 r, l = tand_(r - h_2, r + h_2, roll_hfov=r) # noqa: E741 l is ell 

312 r *= -a # negate right positive 

313 l *= -a # noqa: E741 l is ell 

314 y = a * cotd(e, tilt_vfov=e) 

315 return (l, y), (r, y) 

316 

317 def _xyz5(b, xy5, z, ltp): 

318 # rotate (x, y)'s by bearing, clockwise 

319 sc = sincos2d(b) 

320 for x, y in xy5: 

321 yield Xyz4Tuple(fdot(sc, x, y), 

322 fdot(sc, -x, y), z, ltp) 

323 

324 try: 

325 a, t, y, r = alt_attitude.atyr 

326 except AttributeError: 

327 a, t, y, r = alt_attitude, tilt, yaw, roll 

328 

329 a = Meter(altitude=a) 

330 if a < EPS: # too low 

331 raise _ValueError(altitude=a) 

332 if z: # PYCHOK no cover 

333 z = Meter(z=z) 

334 a -= z 

335 if a < EPS: # z above a 

336 raise _ValueError(altitude_z=a) 

337 else: 

338 z = _0_0 

339 

340 b = Degrees(yaw=y, wrap=wrap360) # bearing 

341 e = -Degrees(tilt=t, wrap=wrap180) # elevation, pitch 

342 if not EPS < e < _180_0: 

343 raise _ValueError(tilt=t) 

344 if e > _90_0: 

345 e = _loneg(e) 

346 b = _umod_360(b + _180_0) 

347 

348 r = Degrees(roll=r, wrap=wrap180) # roll center 

349 x = (-a * tand(r, roll=r)) if r else _0_0 

350 y = a * cotd(e, tilt=t) # ground range 

351 if fabs(y) < EPS: 

352 y = _0_0 

353 

354 v, h, t = self._v_2, self._h_2, self._tan_h_2 

355 # center and corners, clockwise from upperleft, rolled 

356 xy5 = ((x, y),) + _xy2(a, e - v, h, t, r) \ 

357 + _xy2(a, e + v, -h, -t, r) # swapped 

358 # turn center and corners by yaw, clockwise 

359 p = self.ltp if ltp is None else ltp # None OK 

360 return Footprint5Tuple(_xyz5(b, xy5, z, p), **name) # *_xyz5 

361 

362 @Property_RO 

363 def hfov(self): 

364 '''Get the horizontal C{fov} (C{degrees}). 

365 ''' 

366 return Degrees(hfov=self._h_2 * _2_0) 

367 

368 @Property_RO 

369 def ltp(self): 

370 '''Get the I{local tangent plane} (L{Ltp}) or C{None}. 

371 ''' 

372 return self._ltp 

373 

374 def toStr(self, prec=3, fmt=Fmt.F, sep=_COMMASPACE_): # PYCHOK signature 

375 '''Convert this frustum to a "hfov, vfov, ltp" string. 

376 

377 @kwarg prec: Number of (decimal) digits, unstripped (0..8 or C{None}). 

378 @kwarg fmt: Optional, C{float} format (C{letter}). 

379 @kwarg sep: Separator to join (C{str}). 

380 

381 @return: Frustum in the specified form (C{str}). 

382 ''' 

383 t = self.hfov, self.vfov 

384 if self.ltp: 

385 t += self.ltp, 

386 t = strs(t, prec=prec, fmt=fmt) 

387 return sep.join(t) if sep else t 

388 

389 @Property_RO 

390 def vfov(self): 

391 '''Get the vertical C{fov} (C{degrees}). 

392 ''' 

393 return Degrees(vfov=self._v_2 * _2_0) 

394 

395 

396class LocalError(_ValueError): 

397 '''A L{LocalCartesian} or L{Ltp} related issue. 

398 ''' 

399 pass 

400 

401 

402class LocalCartesian(_NamedBase): 

403 '''Conversion between geodetic C{(lat, lon, height)} and I{local 

404 cartesian} C{(x, y, z)} coordinates with I{geodetic} origin 

405 C{(lat0, lon0, height0)}, transcoded from I{Karney}'s C++ class 

406 U{LocalCartesian<https://GeographicLib.SourceForge.io/C++/doc/ 

407 classGeographicLib_1_1LocalCartesian.html>}. 

408 

409 The C{z} axis is normal to the ellipsoid, the C{y} axis points due 

410 North. The plane C{z = -height0} is tangent to the ellipsoid. 

411 

412 The conversions all take place via geocentric coordinates using a 

413 geocentric L{EcefKarney}, by default the WGS84 datum/ellipsoid. 

414 

415 @see: Class L{Ltp}. 

416 ''' 

417 _Ecef = EcefKarney 

418 _ecef = EcefKarney(_WGS84) 

419 _lon00 = INT0 # self.lon0 

420 _9t0 = None # origin (..., lat0, lon0, height0, ...) L{Ecef9Tuple} 

421 _9Tuple = Local9Tuple 

422 

423 def __init__(self, latlonh0=INT0, lon0=INT0, height0=INT0, ecef=None, **lon00_name): 

424 '''New L{LocalCartesian} converter. 

425 

426 @kwarg latlonh0: The (geodetic) origin (C{LatLon}, L{LatLon4Tuple}, L{Ltp} 

427 L{LocalCartesian} or L{Ecef9Tuple}) or the C{scalar} 

428 latitude of the (goedetic) origin (C{degrees}). 

429 @kwarg lon0: Longitude of the (goedetic) origin (C{degrees}), required if 

430 B{C{latlonh0}} is C{scalar}, ignored otherwise. 

431 @kwarg height0: Optional height (C{meter}, conventionally) at the (goedetic) 

432 origin perpendicular to and above (or below) the ellipsoid's 

433 surface, like B{C{lon0}}. 

434 @kwarg ecef: An ECEF converter (L{EcefKarney} I{only}), like B{C{lon0}}. 

435 @kwarg lon00_name: Optional C{B{name}=NN} (C{str}) and keyword argument 

436 C{B{lon00}=B{lon0}} for the arbitrary I{polar} longitude 

437 (C{degrees}), see method C{reverse} and property C{lon00} 

438 for further details. 

439 

440 @raise LocalError: If B{C{latlonh0}} not C{LatLon}, L{LatLon4Tuple}, L{Ltp}, 

441 L{LocalCartesian} or L{Ecef9Tuple} or B{C{latlonh0}}, 

442 B{C{lon0}}, B{C{height0}} or B{C{lon00}} invalid. 

443 

444 @raise TypeError: Invalid B{C{ecef}} or not L{EcefKarney}. 

445 

446 @note: If BC{latlonh0} is an L{Ltp} or L{LocalCartesian}, only C{lat0}, C{lon0}, 

447 C{height0} and I{polar} C{lon00} are copied, I{not} the ECEF converter. 

448 ''' 

449 self.reset(latlonh0, lon0=lon0, height0=height0, ecef=ecef, **lon00_name) 

450 

451 def __eq__(self, other): 

452 '''Compare this and an other instance. 

453 

454 @arg other: The other ellipsoid (L{LocalCartesian} or L{Ltp}). 

455 

456 @return: C{True} if equal, C{False} otherwise. 

457 ''' 

458 return other is self or (isinstance(other, self.__class__) and 

459 other.ecef == self.ecef and 

460 other._9t0 == self._9t0) 

461 

462 @Property_RO 

463 def datum(self): 

464 '''Get the ECEF converter's datum (L{Datum}). 

465 ''' 

466 return self.ecef.datum 

467 

468 @Property_RO 

469 def ecef(self): 

470 '''Get the ECEF converter (L{EcefKarney}). 

471 ''' 

472 return self._ecef 

473 

474 def _ecef2local(self, ecef, Xyz, name_Xyz_kwds): # in _EcefLocal._Ltp_ecef2local 

475 '''(INTERNAL) Convert geocentric/geodetic to local, like I{forward}. 

476 

477 @arg ecef: Geocentric (and geodetic) (L{Ecef9Tuple}). 

478 @arg Xyz: An L{XyzLocal}, L{Aer}, L{Enu} or L{Ned} I{class} or C{None}. 

479 @arg name_Xyz_kwds: Optional C{B{name}=NN} (C{str}) and optionally, 

480 additional B{C{Xyz}} keyword arguments, ignored if C{B{Xyz} 

481 is None}. 

482 

483 @return: An C{B{Xyz}(x, y, z, ltp, **B{name_Xyz_kwds}} instance or 

484 if C{B{Xyz} is None}, a L{Local9Tuple}C{(x, y, z, lat, lon, 

485 height, ltp, ecef, M)} with this C{ltp}, B{C{ecef}} 

486 (L{Ecef9Tuple}) converted to this C{datum} and C{M=None}, 

487 always. 

488 

489 @raise TypeError: Invalid B{C{Xyz}} or B{C{name_Xyz_kwds}} item. 

490 ''' 

491 _xinstanceof(Ecef9Tuple, ecef=ecef) 

492 if ecef.datum != self.datum: 

493 ecef = ecef.toDatum(self.datum) 

494 n, kwds = _name2__(name_Xyz_kwds, _or_nameof=ecef) 

495 x, y, z = self.M.rotate(ecef.xyz, *self._9t0_xyz) 

496 r = Local9Tuple(x, y, z, ecef.lat, ecef.lon, ecef.height, 

497 self, ecef, None, name=n) 

498 if Xyz: 

499 _xsubclassof(*_XyzLocals4, Xyz=Xyz) # Vector3d 

500 r = r.toXyz(Xyz=Xyz, name=n, **kwds) 

501 return r 

502 

503 @Property_RO 

504 def ellipsoid(self): 

505 '''Get the ECEF converter's ellipsoid (L{Ellipsoid}). 

506 ''' 

507 return self.ecef.datum.ellipsoid 

508 

509 def forward(self, latlonh, lon=None, height=0, M=False, **name): 

510 '''Convert I{geodetic} C{(lat, lon, height)} to I{local} cartesian 

511 C{(x, y, z)}. 

512 

513 @arg latlonh: Either a C{LatLon}, L{Ltp}, L{Ecef9Tuple} or C{scalar} 

514 (geodetic) latitude (C{degrees}). 

515 @kwarg lon: Optional C{scalar} (geodetic) longitude (C{degrees}) iff 

516 B{C{latlonh}} is C{scalar}, ignored otherwise. 

517 @kwarg height: Optional height (C{meter}, conventionally) perpendicular 

518 to and above (or below) the ellipsoid's surface, iff 

519 B{C{latlonh}} is C{scalar}, ignored otherwise. 

520 @kwarg M: Optionally, return the I{concatenated} rotation L{EcefMatrix}, 

521 iff available (C{bool}). 

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

523 

524 @return: A L{Local9Tuple}C{(x, y, z, lat, lon, height, ltp, ecef, M)} 

525 with I{local} C{x}, C{y}, C{z}, I{geodetic} C{(lat}, C{lon}, 

526 C{height}, this C{ltp}, C{ecef} (L{Ecef9Tuple}) with 

527 I{geocentric} C{x}, C{y}, C{z} (and I{geodetic} C{lat}, 

528 C{lon}, C{height}) and the I{concatenated} rotation matrix 

529 C{M} (L{EcefMatrix}) if requested. 

530 

531 @raise LocalError: If B{C{latlonh}} not C{scalar}, C{LatLon}, L{Ltp}, 

532 L{Ecef9Tuple} or invalid or if B{C{lon}} not 

533 C{scalar} for C{scalar} B{C{latlonh}} or invalid 

534 or if B{C{height}} invalid. 

535 ''' 

536 lat, lon, h, n = _llhn4(latlonh, lon, height, Error=LocalError, **name) 

537 t = self.ecef._forward(lat, lon, h, n, M=M) 

538 x, y, z = self.M.rotate(t.xyz, *self._9t0_xyz) 

539 m = self.M.multiply(t.M) if M else None 

540 return self._9Tuple(x, y, z, lat, lon, h, self, t, m, name=n or self.name) 

541 

542 @Property_RO 

543 def height0(self): 

544 '''Get the origin's height (C{meter}). 

545 ''' 

546 return self._9t0.height 

547 

548 @Property_RO 

549 def lat0(self): 

550 '''Get the origin's latitude (C{degrees}). 

551 ''' 

552 return self._9t0.lat 

553 

554 @Property_RO 

555 def latlonheight0(self): 

556 '''Get the origin's lat-, longitude and height (L{LatLon3Tuple}C{(lat, lon, height)}). 

557 ''' 

558 return LatLon3Tuple(self.lat0, self.lon0, self.height0, name=self.name) 

559 

560 def _local2ecef(self, local, nine=False, M=False): 

561 '''(INTERNAL) Convert I{local} to geocentric/geodetic, like I{.reverse}. 

562 

563 @arg local: Local (L{XyzLocal}, L{Enu}, L{Ned}, L{Aer} or L{Local9Tuple}). 

564 @kwarg nine: If C{True}, return a 9-, otherwise a 3-tuple (C{bool}). 

565 @kwarg M: Include the rotation matrix (C{bool}). 

566 

567 @return: A I{geocentric} 3-tuple C{(x, y, z)} or if C{B{nine}=True}, an 

568 L{Ecef9Tuple}C{(x, y, z, lat, lon, height, C, M, datum)} with 

569 rotation matrix C{M} (L{EcefMatrix}) if requested. 

570 ''' 

571 _xinstanceof(*_XyzLocals5, local=local) 

572 t = self.M.unrotate(local.xyz, *self._9t0_xyz) 

573 if nine: 

574 t = self.ecef.reverse(*t, M=M) 

575 return t 

576 

577 @Property_RO 

578 def lon0(self): 

579 '''Get the origin's longitude (C{degrees}). 

580 ''' 

581 return self._9t0.lon 

582 

583 @Property 

584 def lon00(self): 

585 '''Get the arbitrary, I{polar} longitude (C{degrees}). 

586 ''' 

587 return self._lon00 

588 

589 @lon00.setter # PYCHOK setter! 

590 def lon00(self, lon00): 

591 '''Set the arbitrary, I{polar} longitude (C{degrees}). 

592 ''' 

593 # lon00 <https://GitHub.com/mrJean1/PyGeodesy/issues/77> 

594 self._lon00 = Degrees(lon00=lon00) 

595 

596 @Property_RO 

597 def M(self): 

598 '''Get the rotation matrix (C{EcefMatrix}). 

599 ''' 

600 return self._9t0.M 

601 

602 def reset(self, latlonh0=INT0, lon0=INT0, height0=INT0, ecef=None, **lon00_name): 

603 '''Reset this converter, see L{LocalCartesian.__init__} for further details. 

604 ''' 

605 _, name = _xkwds_pop2(lon00_name, lon00=None) # PYCHOK get **name 

606 if isinstance(latlonh0, LocalCartesian): 

607 if self._9t0: 

608 _update_all(self) 

609 self._ecef = latlonh0.ecef 

610 self._lon00 = latlonh0.lon00 

611 self._9t0 = latlonh0._9t0 

612 n = _name__(name, _or_nameof=latlonh0) 

613 else: 

614 n = _name__(name, _or_nameof=self) 

615 lat0, lon0, height0, n = _llhn4(latlonh0, lon0, height0, suffix=_0_, 

616 Error=LocalError, name=n) 

617 if ecef: # PYCHOK no cover 

618 _xinstanceof(self._Ecef, ecef=ecef) 

619 _update_all(self) 

620 self._ecef = ecef 

621 elif self._9t0: 

622 _update_all(self) 

623 self._9t0 = self.ecef._forward(lat0, lon0, height0, n, M=True) 

624 self.lon00 = _xattr(latlonh0, lon00=_xkwds_get(lon00_name, lon00=lon0)) 

625 if n: 

626 self.rename(n) 

627 

628 def reverse(self, xyz, y=None, z=None, M=False, **lon00_name): 

629 '''Convert I{local} C{(x, y, z)} to I{geodetic} C{(lat, lon, height)}. 

630 

631 @arg xyz: A I{local} (L{XyzLocal}, L{Enu}, L{Ned}, L{Aer}, L{Local9Tuple}) or 

632 local C{x} coordinate (C{scalar}). 

633 @kwarg y: Local C{y} coordinate (C{meter}), iff B{C{xyz}} is C{scalar}, 

634 ignored otherwise. 

635 @kwarg z: Local C{z} coordinate (C{meter}), iff B{C{xyz}} is C{scalar}, 

636 ignored otherwise. 

637 @kwarg M: Optionally, return the I{concatenated} rotation L{EcefMatrix}, iff 

638 available (C{bool}). 

639 @kwarg lon00_name: Optional C{B{name}=NN} (C{str}) and keyword argument 

640 C{B{lon00}=B{lon0}} for the arbitrary I{polar} longitude 

641 (C{degrees}), overriding see the property C{B{lon00}=B{lon0}} 

642 value. The I{polar} longitude (C{degrees}) is returned with 

643 I{polar} latitudes C{abs(B{lat0}) == 90} for local C{B{x}=0} 

644 and C{B{y}=0} locations. 

645 

646 @return: A L{Local9Tuple}C{(x, y, z, lat, lon, height, ltp, ecef, M)} with 

647 I{local} C{x}, C{y}, C{z}, I{geodetic} C{lat}, C{lon}, C{height}, 

648 this C{ltp}, an C{ecef} (L{Ecef9Tuple}) with the I{geocentric} C{x}, 

649 C{y}, C{z} (and I{geodetic} C{lat}, C{lon}, C{height}) and the 

650 I{concatenated} rotation matrix C{M} (L{EcefMatrix}) if requested. 

651 

652 @raise LocalError: Invalid B{C{xyz}} or C{scalar} C{x} or B{C{y}} and/or B{C{z}} 

653 not C{scalar} for C{scalar} B{C{xyz}}. 

654 ''' 

655 lon00, name =_xkwds_pop2(lon00_name, lon00=self.lon00) 

656 x, y, z, n = _xyzn4(xyz, y, z, _XyzLocals5, Error=LocalError, name=name) 

657 c = self.M.unrotate((x, y, z), *self._9t0_xyz) 

658 t = self.ecef.reverse(*c, M=M, lon00=lon00) 

659 m = self.M.multiply(t.M) if M else None 

660 return self._9Tuple(x, y, z, t.lat, t.lon, t.height, self, t, m, name=n or self.name) 

661 

662 @Property_RO 

663 def _9t0_xyz(self): 

664 '''(INTERNAL) Get C{(x0, y0, z0)} as L{Vector3Tuple}. 

665 ''' 

666 return self._9t0.xyz 

667 

668 def toStr(self, prec=9, **unused): # PYCHOK signature 

669 '''Return this L{LocalCartesian} as a string. 

670 

671 @kwarg prec: Precision, number of (decimal) digits (0..9). 

672 

673 @return: This L{LocalCartesian} representation (C{str}). 

674 ''' 

675 return self.attrs(_lat0_, _lon0_, _height0_, _M_, _ecef_, _name_, prec=prec) 

676 

677 

678class Ltp(LocalCartesian): 

679 '''A I{local tangent plan} (LTP), a sub-class of C{LocalCartesian} with 

680 (re-)configurable ECEF converter. 

681 ''' 

682 _Ecef = _EcefBase 

683 

684 def __init__(self, latlonh0=INT0, lon0=INT0, height0=INT0, ecef=None, **lon00_name): 

685 '''New C{Ltp}, see L{LocalCartesian.__init__} for more details. 

686 

687 @kwarg ecef: Optional ECEF converter (L{EcefKarney}, L{EcefFarrell21}, 

688 L{EcefFarrell22}, L{EcefSudano}, L{EcefVeness} or L{EcefYou} 

689 I{instance}), overriding the default L{EcefKarney}C{(datum=Datums.WGS84)} 

690 for C{scalar} B{C{latlonh0}}. 

691 

692 @see: Class L{LocalCartesian<LocalCartesian.__init__>} for further details. 

693 

694 @raise TypeError: Invalid B{C{ecef}}. 

695 ''' 

696 LocalCartesian.reset(self, latlonh0, lon0=lon0, height0=height0, 

697 ecef=ecef, **lon00_name) 

698 

699 @Property 

700 def ecef(self): 

701 '''Get this LTP's ECEF converter (C{Ecef...} I{instance}). 

702 ''' 

703 return self._ecef 

704 

705 @ecef.setter # PYCHOK setter! 

706 def ecef(self, ecef): 

707 '''Set this LTP's ECEF converter (C{Ecef...} I{instance}). 

708 

709 @raise TypeError: Invalid B{C{ecef}}. 

710 ''' 

711 _xinstanceof(_EcefBase, ecef=ecef) 

712 if self._ecef != ecef: # PYCHOK no cover 

713 self.reset(self._9t0) 

714 self._ecef = ecef 

715 

716 

717class LqRD(Ltp): 

718 '''A I{local tangent plan} (LTP) for conversion between I{GRS80 (ETRS89) geodetic} and 

719 I{local Netherlands}' C{quasi-B{R}ijksB{D}riehoeksmeting (RD)} coordinates. 

720 

721 This C{quasi-RD} transformer B{does not} implement any U{RD NAP<https://www.NSGI.NL/ 

722 coordinatenstelsels-en-transformaties/coordinatentransformaties/rdnap-etrs89-rdnaptrans>} 

723 specification and B{does not} provide I{Netherlands}' C{B{N}ormaal B{A}msterdams B{P}eil 

724 (NAP)} quasi-geodetic-height. 

725 

726 The L{LqRD.forward} C{x} and C{y} results differ 3 meter near the center up to 600 meter 

727 at the corners of the L{RD region<LqRD.region4>} with C{RDx} and C{RDy} values from 

728 formal C{RD NAP 2018} implementations like U{pyrdnap<https://PyPI.org/project/pyrdnap>}. 

729 

730 The L{LqRD.forward} C{z} values represent perpendicular distances to this local tangent 

731 plane (LTP). Other heights in L{LqRD} are I{GRS80 (ETRS89) heights} above (or below) 

732 the ellipsoid's surface. B{None} are C{NAP} quasi-geodetic-heights. 

733 ''' 

734 Amersfoort = LatLon4Tuple(parseDMS('52 9 22.178N'), # height=0.0, not .height0_ETRS! 

735 parseDMS(' 5 23 15.5E'), _0_0, _GRS80, name='Amersfoort') 

736# _Ecef = _EcefBase 

737 _ecef = EcefKarney(_GRS80) 

738 _x0 = Meter(x0=155029.8) # 155000.0 see pyrdnap -v1 -forward Amersfoort.latlon 

739 _y0 = Meter(y0=463109.9) # 463000.0 see pyrdnap -v1 -forward Amersfoort.latlon 

740 

741 def __init__(self, latlonh0=Amersfoort, **other_Ltp_kwds): 

742 '''New ECEF-based I{GRS80 (ETRS89)} L{LqRD} converter, centered at I{Amersfoort, NL}. 

743 

744 @kwarg latlonh0: The I{geodetic} origin and height, overriding C{Amersfoort}. 

745 @kwarg other_Ltp_kwds: Optional, other L{Ltp.__init__} keyword arguments. 

746 

747 @see: Class L{Ltp<Ltp.__init__>} for more information. 

748 ''' 

749 Ltp.__init__(self, latlonh0, **_xkwds(other_Ltp_kwds, ecef=None, name=LqRD.Amersfoort.name)) 

750 

751 def _asRD(self, b4): # bounds of C{r} as C{RD4Tuple} 

752 _xinstanceof(Bounds4Tuple, b4=b4) 

753 S, W, N, E = b4 

754 b = self.forward(S, W) 

755 t = self.forward(N, E) 

756 # assert b.x < t.x and b.y < t.y 

757 return RD4Tuple(b.x, b.y, t.x, t.y, name=b4.name) 

758 

759 def bounds4(self, asRD=False): 

760 '''Get the South, West, North and East bounds of the Netherlands' U{EEZ 

761 <http://MarineRegions.org/mrgid/5668>} and U{EPSG:28992<https://EPSG.io/28992>}. 

762 

763 @kwarg asRd: Use C{B{asRD}=True} for the bounds in C{meter}, otherwise in 

764 C{degrees} (C{bool}). 

765 

766 @return: A L{Bounds4Tuple}C{(latS, lonW, latN, lonE)} with lat- and longitudes 

767 in C{degrees} or an L{RD4Tuple}C{(minRDx, minRDy, maxRDx, maxRDy)} with 

768 the C{quasi-RD} bounds in C{meter}. 

769 

770 @see: U{EEZ<https://NL.WikiPedia.org/wiki/Nederlandse_Exclusieve_Economische_Zone>} 

771 ''' 

772 b = self._bounds4 

773 return self._asRD(b) if asRD else b 

774 

775 @property_ROver 

776 def _bounds4(self): # as RD-Bessel L{Bounds4Tuple} 

777 b = Bounds4Tuple('51 19 48.6', '2 32 21.6', '55 45 54', '7 12 37') # EEZ 

778 b = b.toUnits().union(50.75, 3.2, 53.7, 7.22) # EPSG:28992 

779 return b.toUnits(name='RD bounds ') 

780 

781 def forward(self, lat_latlonh, lon=None, height=0, **M_name): # PYCHOK signature 

782 '''Convert I{geodetic} C{(lat, lon, height)} to I{local} C{quasi-RD (x, y, z)}. 

783 

784 @return: A L{Local9Tuple}C{(x, y, z, lat, lon, height, ltp, ecef, M)}. 

785 

786 @see: Method L{LocalCartesian.forward} for more information. 

787 ''' 

788 r = Ltp.forward(self, lat_latlonh, lon, height, **M_name) 

789 return r.dup(x=r.x + self.x0, y=r.y + self.y0, name=r.name) 

790 

791 @property_ROver 

792 def height0_ETRS(self): 

793 '''Get C{Amersfoort}'s I{GRS80 (ETRS89) height} (C{Meter}). 

794 ''' 

795 return Meter(height0_ETRS=43.0) # see pyrdnap h0_ETRS 

796 

797 @deprecated_property_RO 

798 def region(self): # PYCHOK no cover 

799 '''DEPRECATED on 2026.06.12, use method L{pygeodesy.LqRD.region4()}.''' 

800 return self._region4 

801 

802 def region4(self, asRD=False): # in pyrdnap.rd0._RD 

803 '''Get the South, West, North and East bounds of the C{RD} region. 

804 

805 @kwarg asRd: Use C{B{asRD}=True} for the bounds in C{meter}, 

806 otherwise in C{degrees} (C{bool}). 

807 

808 @return: A L{Bounds4Tuple}C{(latS, lonW, latN, lonE)} with lat- and 

809 longitudes in C{degrees} or an L{RD4Tuple}C{(minRDx, minRDy, 

810 maxRDx, maxRDy)} with the C{quasi-RD} bounds in C{meter}. 

811 ''' 

812 r = self._region4 

813 return self._asRD(r) if asRD else r 

814 

815# @property_ROver 

816# def _region4ETRS(self): # as ETRS (ETRS89) L{Bounds4Tuple} 

817# return Bounds4Tuple('49 59 57.39', '2 0 0.12', 

818# '55 59 54.82', '7 59 56.97').toUnits(name='ETRS region ') 

819 

820 @property_ROver 

821 def _region4(self): # as RD-Bessel L{Bounds4Tuple} 

822 return Bounds4Tuple(50.0, _2_0, 56.0, _8_0).toUnits(name='RD region ') 

823 

824 def reverse(self, x_xyz, y=None, z=None, **M_name): # PYCHOK signature 

825 '''Convert I{local} C{quasi-RD (x, y, z)} to I{geodetic} C{(lat, lon, height)}. 

826 

827 @return: A L{Local9Tuple}C{(x, y, z, lat, lon, height, ltp, ecef, M)}. 

828 

829 @see: Method L{LocalCartesian.reverse} for more information. 

830 ''' 

831 x, y, z = x_xyz.xyz if y is z is None else map1(Meter, x_xyz, y, z) 

832 r = Ltp.reverse(self, x - self.x0, y - self.y0, z, **M_name) 

833 return r.dup(x=x, y=y, name=r.name) 

834 

835 @property_doc_(' the C{quasi-RD} false Easting (C{meter}).') 

836 def x0(self): 

837 return self._x0 

838 

839 @x0.setter # PYCHOK setter! 

840 def x0(self, meter): 

841 self._x0 = Meter(x0=meter) 

842 

843 @property_doc_(' the C{quasi-RD} false Northing (C{meter}).') 

844 def y0(self): 

845 return self._y0 

846 

847 @y0.setter # PYCHOK setter! 

848 def y0(self, meter): 

849 self._y0 = Meter(y0=meter) 

850 

851 

852def _fov_2(**fov): 

853 # Half a field-of-view angle in C{degrees}. 

854 f = Degrees(Error=LocalError, **fov) * _0_5 

855 if EPS < f < _90_0: 

856 return f 

857 t = _invalid_ if f < 0 else _too_(_wide_ if f > EPS else _narrow_) 

858 raise LocalError(txt=t, **fov) 

859 

860 

861def tyr3d(tilt=INT0, yaw=INT0, roll=INT0, Vector=Vector3d, **name_Vector_kwds): 

862 '''Convert an attitude pose into a (3-D) direction vector. 

863 

864 @kwarg tilt: Pitch, elevation from horizontal (C{degrees}), negative down 

865 (clockwise rotation along and around the x-axis). 

866 @kwarg yaw: Bearing, heading (compass C{degrees360}), clockwise from North 

867 (counter-clockwise rotation along and around the z-axis). 

868 @kwarg roll: Roll, bank (C{degrees}), positive to the right and down 

869 (clockwise rotation along and around the y-axis). 

870 @kwarg Vector: Class to return the direction vector (C{Cartesian}, 

871 L{Vector3d} or C{Vector3Tuple}) or C{None}. 

872 @kwarg name_Vector_kwds: Optional C{B{name}=NN} (C{str}) and optionally, 

873 additional B{C{Vector}} keyword arguments, ignored if C{B{Vector} 

874 is None}. 

875 

876 @return: A named B{C{Vector}} instance or if C{B{Vector} is None}, 

877 a named L{Vector3Tuple}C{(x, y, z)}. 

878 

879 @raise AttitudeError: Invalid B{C{tilt}}, B{C{yaw}} or B{C{roll}}. 

880 

881 @raise TypeError: Invalid B{C{Vector}} or B{C{name_Vector_kwds}}. 

882 

883 @see: U{Yaw, pitch, and roll rotations<http://MSL.CS.UIUC.edu/planning/node102.html>} 

884 and function L{pygeodesy.hartzell} argument C{los}, Line-Of-Sight. 

885 ''' 

886 v = Attitude4Tuple(_0_0, tilt, yaw, roll).tyr3d 

887 if Vector is not type(v): 

888 n, kwds = _name2__(name_Vector_kwds, name__=tyr3d) 

889 v = Vector3Tuple(v.x, v.y, v.z, name=n) if Vector is None else \ 

890 Vector(v.x, v.y, v.z, name=n, **kwds) 

891 elif name_Vector_kwds: 

892 n, _ = _name2__(name_Vector_kwds) 

893 if n: 

894 v = v.copy(name=n) 

895 return v 

896 

897 

898def _xLtp(ltp, *dflt): 

899 '''(INTERNAL) Validate B{C{ltp}} if not C{None} else B{C{dflt}}. 

900 ''' 

901 if dflt and ltp is None: 

902 ltp = dflt[0] 

903 _xinstanceof(Ltp, LocalCartesian, ltp=ltp) 

904 return ltp 

905 

906# **) MIT License 

907# 

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

909# 

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

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

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

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

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

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

916# 

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

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

919# 

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

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

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

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

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

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

926# OTHER DEALINGS IN THE SOFTWARE.