1 2============== 3 Design Notes 4============== 5 6.. currentmodule:: skyfield.units 7 8This document collects various notes 9about the API design of the Skyfield library, 10as well as the code samples that illustrate them 11and make sure that they keep working. 12 13Position classes with coordinate methods 14======================================== 15 16One of the biggest sources of confusion in PyEphem was that it offered 17only a single routine to generate right ascension and declination 18coordinates, whose arguments selected both what kind of position to 19compute — astrometric or apparent — and also what coordinate system to 20use to represent those different positions in the sky. 21 22Users were chronically confused about which position they were asking 23for, and what kind of coordinates were being used to name that position. 24 25Skyfield therefore keeps these concepts strictly separate, by following 26these rules in its API design: 27 28* Positions are a big deal. After all, in real life, two different 29 positions are two different places up in the sky. If beneath the 30 night sky you point with your arm at one position, and then at another 31 position that is not equal to the first, you will have to physically 32 move your arm from the first to the second. 33 34* Coordinates are not as big a deal. They are merely names. Given a 35 position, you can name it with ICRS coordinates or equinox-of-date 36 coordinates; with equatorial or ecliptic or galactic coordinates; but 37 even as you step through all of those choices of coordinate, rattling 38 off different numbers for your audience, your arm will remain in the 39 exact same position. Your finger will be pointing at exactly one 40 place in the sky that all those different coordinates designate. 41 42Therefore Skyfield deems each position as substantial enough to deserve 43its own Python object. If you convert an astrometric position into an 44apparent position, you will be returned a new object to represent that 45different place in the sky:: 46 47 apparent = astrometric.apparent() 48 49But coordinates are mere names and so do not earn a separate object for 50each choice of coordinate. Instead, each kind of coordinate is simply a 51different method call on the position you have generated:: 52 53 astrometric.radec() 54 astrometric.ecliptic_latlon() 55 astrometric.galactic_latlon() 56 57This approach stands in contrast to both PyEphem and its one method that 58generated all combinations of position and coordinate, and with AstroPy, 59which dignifies different coordinates with different objects even if 60those two coordinates designate exactly the same place in the sky. 61Skyfield instead tries to use the object-versus-method distinction in 62Python to provide extra signal to the amateur about when a program is 63talking about a truly different location in the sky, versus when it is 64merely generating different numbers to designate a single location. 65 66Angle Classes 67============= 68 69Years of experience with maintaining PyEphem suggested that many users 70who are first dabbling in astronomy do not realize that right ascension 71is not typically expressed in degrees, and that users will frequently 72become confused — generating a long support tail — if the API makes it 73too natural to express right ascension using degrees. 74 75Therefore, when faced with a request for degrees or hours, the 76:class:`Angle` class tries to protect the user from accessing the wrong 77one unless they have indicated that it is the value they really want. 78By default, all angles assume that degrees are the proper unit, and have 79no problem with the user accessing them as degrees: 80 81>>> from skyfield.units import Angle 82>>> a = Angle(degrees=-54.0) 83>>> a.degrees 84-54.0 85>>> print(a.dstr()) 86-54deg 00' 00.0" 87>>> a.dms() 88(-54.0, -0.0, -0.0) 89>>> a.signed_dms() 90(-1.0, 54.0, 0.0, 0.0) 91 92But exceptions will trigger if we try to express the angle in hours: 93 94>>> a.hours 95Traceback (most recent call last): 96 ... 97WrongUnitError: this angle is usually expressed in degrees, not hours; if you want to use hours anyway, then please use the attribute _hours 98>>> a.hstr() 99Traceback (most recent call last): 100 ... 101WrongUnitError: this angle is usually expressed in degrees, not hours; if you want to use hours anyway, then call signed_hms() with warn=False 102>>> a.hms() 103Traceback (most recent call last): 104 ... 105WrongUnitError: this angle is usually expressed in degrees, not hours; if you want to use hours anyway, then call signed_hms() with warn=False 106>>> a.signed_hms() 107Traceback (most recent call last): 108 ... 109WrongUnitError: this angle is usually expressed in degrees, not hours; if you want to use hours anyway, then call signed_hms() with warn=False 110 111The remedies suggested by these exceptions indeed work: 112 113>>> print(a._hours) 114-3.6 115>>> print(a.hstr(warn=False)) 116-03h 36m 00.00s 117>>> a.hms(warn=False) 118(-3.0, -36.0, -0.0) 119>>> a.signed_hms(warn=False) 120(-1.0, 3.0, 36.0, 0.0) 121 122An angle has the opposite behavior if it is given a preference of hours 123when it is created. In that case, fetching its value in hours values 124becomes the easy operation: 125 126>>> a = Angle(degrees=-54.0, preference='hours') 127>>> print(a.hours) 128-3.6 129>>> print(a.hstr()) 130-03h 36m 00.00s 131>>> a.hms() 132(-3.0, -36.0, -0.0) 133>>> a.signed_hms() 134(-1.0, 3.0, 36.0, 0.0) 135 136And it is now degrees that take a bit of extra effort to retrieve. 137 138>>> a.degrees 139Traceback (most recent call last): 140 ... 141WrongUnitError: this angle is usually expressed in hours, not degrees; if you want to use degrees anyway, then please use the attribute _degrees 142>>> a.dstr() 143Traceback (most recent call last): 144 ... 145WrongUnitError: this angle is usually expressed in hours, not degrees; if you want to use degrees anyway, then call dstr() with warn=False 146>>> a.dms() 147Traceback (most recent call last): 148 ... 149WrongUnitError: this angle is usually expressed in hours, not degrees; if you want to use hours anyway, then call signed_hms() with warn=False 150>>> a.signed_dms() 151Traceback (most recent call last): 152 ... 153WrongUnitError: this angle is usually expressed in hours, not degrees; if you want to use hours anyway, then call signed_hms() with warn=False 154 155>>> a._degrees 156-54.0 157>>> print(a.dstr(warn=False)) 158-54deg 00' 00.0" 159>>> a.dms(warn=False) 160(-54.0, -0.0, -0.0) 161>>> a.signed_dms(warn=False) 162(-1.0, 54.0, 0.0, 0.0) 163 164Importing NumPy 165=============== 166 167Code examples in the Skyfield documentation that need NumPy 168will always import it as ``np`` 169as that is the standard practice in the wider SciPy community. 170Examples will then use NumPy features 171through fully qualified names like ``np.array`` 172since that is how users — 173especially users new to the scientific Python ecosystem — 174should be advised to structure their own code. 175 176However, because Skyfield code itself 177is presumed to always use NumPy 178in preference to built-in Python numerics, 179the hundreds of ``np.`` prefixes would add only noise. 180As a consequence, Skyfield’s modules themselves simply do a 181``from`` ``numpy`` ``import`` of any names that they need. 182 183Skyfield strives to support old versions of both Python and of NumPy, 184because many users in industry and government 185cannot upgrade their supporting libraries whenever they want. 186So the unit tests in CI are run against NumPy 1.11.3 187to maintain compatibility with that older version. 188 189Rotation matrices or state transformation matrices? 190=================================================== 191 192Instead of keeping position and velocity in separate 3-vectors of 193floats, the SPICE library from the JPL concatenates them both into a 194single 6-vector. It can then express the transform of one reference 195frame into another by a single 6×6 matrix. This is clever, because a 196transformed velocity is the sum of both the frame’s native velocity and 197also a contribution from the angle through which the position vector is 198swept. This is very cleverly captured by the 6×6 matrix; the comments 199in ``frmchg`` illustrate its structure:: 200 201 - - 202 | | 203 | R 0 | 204 | | 205 | | 206 | dR | 207 | -- R | 208 | dt | 209 | | 210 - - 211 212The top rows say “the position is simply rotated, with no contribution 213from the velocity,” while the bottom rows say “the velocity is rotated, 214then added to the position × the rate the frame is rotating.” 215 216Since an aggregate frame transform can then be constructed by simply 217multiplying a series of these 6×6 matrices, a temptation arises: if 218Skyfield frame objects adopted the same convention, they would only have 219to carry a single transformation matrix. 220 221The answer is no. Skyfield does not use this technique. 222 223To understand why, observe the waste that happens when using the above 224matrix: fully one-quarter of the multiplies and something like one-half 225the adds always create zeros. The SPICE system corrects this by using a 226one-off implementation of matrix multiplication ``zzmsxf`` that in fact 227treats the operation as smaller 3×3 operations. Its comments note:: 228 229 - - - - 230 | | | | | | 231 | R2 | 0 | | R1 | 0 | 232 | | | | | | 233 | -----+------ | | -----+------ | = 234 | | | | | | 235 | D2 | R2 | | D1 | R1 | 236 | | | | | | 237 - - - - 238 239 - - 240 | | | 241 | R2*R1 | 0 | 242 | | | 243 | -----------------+------------ | 244 | | | 245 | D2*R1 + R2*D1 | R2*R1 | 246 | | | 247 - - 248 249If the cost of efficiency is the additional cost and complication of 250breaking down the 6×6 so as to discard one-quarter of it and do pairwise 251operations between the remaining three quarters, then Skyfield chooses 252to not perform the aggregation in the first place. 253 254So in Skyfield let’s keep the matrices ``R`` and ``dR/dt`` in the first 255diagram always separate. Then we can perform the exact 3×3 operations 256that SPICE does but without what in Skyfield would be a disaggregation 257step beforehand plus an aggregation step after. 258 259Cross products 260============== 261 262How can we compute a cross product while remaining agnostic about 263whether the two vectors we have been handed have a second dimension? 264 265>>> from numpy import array, cross 266>>> a = array([1, 2, 3]) 267>>> b = array([6, 4, 5]) 268 269>>> p = array([7, 8, 9]) 270>>> q = array([1, 0, 2]) 271 272The simple cross product between our 3-vectors: 273 274>>> cross(a, p, 0, 0).T 275array([-6, 12, -6]) 276>>> cross(b, q, 0, 0).T 277array([ 8, -7, -4]) 278 279Now we combine our vectors into stacks of values across the final 280dimension of a matrix: 281 282>>> ab = array([a, b]).T 283>>> ab 284array([[1, 6], 285 [2, 4], 286 [3, 5]]) 287>>> pq = array([p, q]).T 288>>> pq 289array([[7, 1], 290 [8, 0], 291 [9, 2]]) 292>>> cross(ab, pq, 0, 0).T 293array([[-6, 8], 294 [12, -7], 295 [-6, -4]]) 296 297(Wondering how to do fundamental_arguments() without reshape:) 298 299>>> from numpy import array, cross, matrix, float64 300>>> m = array([4.0,5.0,6.0]) 301>>> arg1 = float64(2.0) 302>>> argn = array([2.0, 3.0]) 303>>> arg1 * m.reshape(3,) 304array([ 8., 10., 12.]) 305>>> argn * m.reshape(3, 1) 306array([[ 8., 12.], 307 [10., 15.], 308 [12., 18.]]) 309