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