Skip to content

Commit 2acb6f2

Browse files
Gave Angle.dstr() and .hstr() a format= parameter
1 parent 62d882d commit 2acb6f2

File tree

4 files changed

+187
-22
lines changed

4 files changed

+187
-22
lines changed

CHANGELOG.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@ Changelog
55
.. TODO After finding how to test TIRS reference frame, add it to changelog.
66
And double-check the constellation boundaries array.
77
8-
v1.39 — ?
9-
---------
8+
v1.39 — 2021 April 14
9+
---------------------
10+
11+
* The
12+
:meth:`Angle.dstr() <skyfield.units.Angle.dstr>`
13+
and
14+
:meth:`Angle.hstr() <skyfield.units.Angle.hstr>`
15+
methods now accept a ``format=`` argument
16+
that lets callers override Skyfield’s default angle formatting
17+
and supply their own; see `Formatting angles`.
18+
`#513 <https://github.com/skyfielders/python-skyfield/issues/513>`_
1019

1120
* The prototype :func:`~skyfield.magnitudelib.planetary_magnitude()`
1221
function now works not only when given a single position, but when

skyfield/documentation/api-units.rst

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ they return instances of the following classes.
77

88
.. testsetup::
99

10-
from skyfield.units import Distance
10+
from skyfield.units import Angle, Distance
1111

1212
.. currentmodule:: skyfield.units
1313

@@ -25,3 +25,139 @@ they return instances of the following classes.
2525

2626
.. autoclass:: Rate
2727
:members:
28+
29+
.. _Formatting angles:
30+
31+
-----------------
32+
Formatting angles
33+
-----------------
34+
35+
To display an angle as decimal degrees or hours,
36+
ask the angle for its ``.hours`` or ``.degrees`` attribute
37+
and then use any normal Python mechanism for formatting a float.
38+
For example:
39+
40+
.. testcode::
41+
42+
ra, dec = Angle(hours=5.5877286), Angle(degrees=-5.38731536)
43+
44+
print('RA {:.8f} hours'.format(ra.hours))
45+
print('Dec {:+.8f} degrees'.format(dec.degrees))
46+
47+
.. testoutput::
48+
49+
RA 5.58772860 hours
50+
Dec -5.38731536 degrees
51+
52+
If you let Skyfield do the formatting instead,
53+
then hours are split into 60 minutes of 60 seconds each,
54+
and degrees are split into 60 arcminutes of 60 arcseconds each:
55+
56+
.. testcode::
57+
58+
print('RA', ra)
59+
print('Dec', dec)
60+
61+
.. testoutput::
62+
63+
RA 05h 35m 15.82s
64+
Dec -05deg 23' 14.3"
65+
66+
If you want more control over the display of minutes and seconds,
67+
you can call an angle’s “hours as a string” method :meth:`~Angle.hstr`
68+
or “degrees as a string” method :meth:`~Angle.dstr`.
69+
The simplest adjustment you can make
70+
is to specify the number of decimal ``places``
71+
that will be shown in the seconds field.
72+
73+
.. testcode::
74+
75+
print('RA', ra.hstr(places=4))
76+
print('Dec', dec.dstr(places=4))
77+
78+
.. testoutput::
79+
80+
RA 05h 35m 15.8230s
81+
Dec -05deg 23' 14.3353"
82+
83+
In each of these examples
84+
you can see that Skyfield marks arcminutes
85+
with the ASCII apostrophe ``'``
86+
and arcseconds
87+
with the ASCII quotation mark ``"``.
88+
Using plain ASCII lets Skyfield
89+
support as many operating systems and output media as possible.
90+
But it would be more correct
91+
to denote arcseconds and arcminutes
92+
with the Unicode symbols PRIME and DOUBLE PRIME,
93+
and to use the Unicode DEGREE SIGN to mark the whole number:
94+
95+
−5°23′14.3″
96+
97+
If you want to override Skyfield’s default notation
98+
to create either the string above, or any other notation,
99+
then give :meth:`~Angle.hstr` or :meth:`~Angle.dstr`
100+
a ``format=`` string of your own.
101+
It should use the syntax of Python’s
102+
`str.format() <https://docs.python.org/3/library/string.html#formatstrings>`_
103+
method.
104+
For example,
105+
here’s the exact string you would use
106+
to format an angle in degrees, arcminutes, and arcseconds
107+
using the traditional typographic symbols discussed above:
108+
109+
.. testcode::
110+
111+
print(dec.dstr(format=u'{0}{1}°{2:02}′{3:02}.{4:0{5}}″'))
112+
113+
.. testoutput::
114+
115+
-5°23′14.3″
116+
117+
(Note that the leading ``u``, for “Unicode”, is only mandatory
118+
in Python 2, not Python 3.)
119+
120+
Skyfield will call your string’s format method
121+
with these six arguments:
122+
123+
{0}
124+
An ASCII hyphen ``'-'`` if the angle is negative,
125+
else the empty string.
126+
If you want positive angles to be decorated with a plus sign,
127+
try using ``{0:+>1}`` which tells Python,
128+
“display positional parameter 0,
129+
padding the field to one character wide
130+
if it’s less than one character already,
131+
and use the ``+`` character to do the padding.”
132+
133+
{1}
134+
The number of whole hours or degrees.
135+
136+
{2}
137+
The number of whole minutes.
138+
139+
{3}
140+
The number of whole seconds.
141+
142+
{4}
143+
The fractions of a second.
144+
Be sure to pad this field
145+
to the number of ``places=`` you’ve requested,
146+
or else a fraction like ``.0012`` will format incorrectly as ``.12``.
147+
If you have asked for ``places=3``, for example,
148+
you’ll want to display this field as ``{4:03}``.
149+
(See also the next item.)
150+
151+
{5}
152+
The number of ``places=`` you requested,
153+
which you will probably use like ``{4:0{5}}``
154+
when formatting field 4.
155+
You can use this
156+
in case you might not know the number of places ahead of time,
157+
for example if the number of places is configured by your user.
158+
159+
It would have been nice if the ``format=`` string
160+
were the first option to :meth:`~Angle.hstr` or :meth:`~Angle.dstr`
161+
so that its keyword name could be omitted
162+
but, alas, it was only added in Skyfield 1.39,
163+
by which point the other options had already grabbed the first spots.

skyfield/tests/test_units.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ def test_angle_scalar_strs():
4646
assert str(Angle(hours=array(12))) == '''12h 00m 00.00s'''
4747

4848
def test_angle_array_strs():
49-
h = Angle(hours=array([0.5, nan, 13]))
49+
h = Angle(hours=array([0.5, nan, -13]))
5050
d = Angle(degrees=h._degrees)
5151

52-
assert str(h) == '3 values from 00h 30m 00.00s to 13h 00m 00.00s'
53-
assert str(d) == '''3 values from 07deg 30' 00.0" to 195deg 00' 00.0"'''
52+
assert str(h) == '3 values from 00h 30m 00.00s to -13h 00m 00.00s'
53+
assert str(d) == '''3 values from 07deg 30' 00.0" to -195deg 00' 00.0"'''
5454

5555
with assert_raises(WrongUnitError):
5656
h.dstr()
@@ -59,19 +59,24 @@ def test_angle_array_strs():
5959
assert h.hstr() == d.hstr(warn=False) == [
6060
'00h 30m 00.00s',
6161
'nan',
62-
'13h 00m 00.00s',
62+
'-13h 00m 00.00s',
6363
]
6464
assert d.dstr() == h.dstr(warn=False) == [
6565
'07deg 30\' 00.0"',
6666
'nan',
67-
'195deg 00\' 00.0"',
67+
'-195deg 00\' 00.0"',
6868
]
6969

7070
empty = Angle(radians=[])
7171
assert str(empty) == 'Angle []'
7272
assert empty.hstr(warn=False) == []
7373
assert empty.dstr() == []
7474

75+
assert h.hstr(format='{0} {1} {2} {3} {4} {5}', places=6) == [
76+
' 0 30 0 0 6', 'nan', '- 13 0 0 0 6']
77+
assert d.dstr(format='{0} {1} {2} {3} {4} {5}', places=6) == [
78+
' 7 30 0 0 6', 'nan', '- 195 0 0 0 6']
79+
7580
def test_angle_sexagesimal_args():
7681
assert str(Angle(degrees=(90,))) == '''90deg 00' 00.0"'''
7782
assert str(Angle(hours=(12,))) == '''12h 00m 00.00s'''

skyfield/units.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
from .descriptorlib import reify
99
from .functions import _to_array, length_of
1010

11-
_dfmt = '{0}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'.format
12-
_dsgn = '{0:+>1}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'.format
13-
_hfmt = '{0}{1:02}h {2:02}m {3:02}.{4:0{5}}s'.format
11+
_dfmt = '{0}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
12+
_dsgn = '{0:+>1}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
13+
_hfmt = '{0}{1:02}h {2:02}m {3:02}.{4:0{5}}s'
1414

1515
class UnpackingError(Exception):
1616
"""You cannot iterate directly over a Skyfield measurement object."""
@@ -319,11 +319,11 @@ def __str__(self):
319319
return 'Angle []'
320320
if self.preference == 'degrees':
321321
v = self._degrees
322-
fmt = _dsgn if self.signed else _dfmt
322+
fmt = _dsgn.format if self.signed else _dfmt.format
323323
places = 1
324324
else:
325325
v = self._hours
326-
fmt = _hfmt
326+
fmt = _hfmt.format
327327
places = 2
328328
if size >= 2:
329329
return '{0} values from {1} to {2}'.format(
@@ -358,15 +358,22 @@ def signed_hms(self, warn=True):
358358
raise WrongUnitError('signed_hms')
359359
return _sexagesimalize_to_float(self._hours)
360360

361-
def hstr(self, places=2, warn=True):
362-
"""Convert to a string like ``12h 07m 30.00s``."""
361+
def hstr(self, places=2, warn=True, format=_hfmt):
362+
"""Return a string like ``12h 07m 30.00s``; see `Formatting angles`.
363+
364+
.. versionadded:: 1.39
365+
366+
Added the ``format=`` parameter.
367+
368+
"""
363369
if warn and self.preference != 'hours':
364370
raise WrongUnitError('hstr')
365371
hours = self._hours
366372
shape = getattr(hours, 'shape', ())
373+
fmt = format.format # `format()` method of `format` string
367374
if shape:
368-
return [_sfmt(_hfmt, h, places) for h in hours]
369-
return _sfmt(_hfmt, hours, places)
375+
return [_sfmt(fmt, h, places) for h in hours]
376+
return _sfmt(fmt, hours, places)
370377

371378
def dms(self, warn=True):
372379
"""Convert to a tuple (degrees, minutes, seconds).
@@ -390,13 +397,21 @@ def signed_dms(self, warn=True):
390397
raise WrongUnitError('signed_dms')
391398
return _sexagesimalize_to_float(self._degrees)
392399

393-
def dstr(self, places=1, warn=True):
394-
"""Convert to a string like ``181deg 52\' 30.0"``."""
400+
def dstr(self, places=1, warn=True, format=None):
401+
"""Return a string like ``181deg 52' 30.0"``; see `Formatting angles`.
402+
403+
.. versionadded:: 1.39
404+
405+
Added the ``format=`` parameter.
406+
407+
"""
395408
if warn and self.preference != 'degrees':
396409
raise WrongUnitError('dstr')
397410
degrees = self._degrees
398411
signed = self.signed
399-
fmt = _dsgn if signed else _dfmt
412+
if format is None:
413+
format = _dsgn if signed else _dfmt
414+
fmt = format.format # `format()` method of `format` string
400415
shape = getattr(degrees, 'shape', ())
401416
if shape:
402417
return [_sfmt(fmt, d, places) for d in degrees]
@@ -472,13 +487,13 @@ def _sexagesimalize_to_int(value, places=0):
472487
n, minutes = divmod(n, 60)
473488
return sign, n, minutes, seconds, fraction
474489

475-
def _sfmt(format, value, places):
490+
def _sfmt(fmt, value, places):
476491
"""Decompose floating point `value` into sexagesimal, and format."""
477492
if isnan(value):
478493
return 'nan'
479494
sgn, h, m, s, fraction = _sexagesimalize_to_int(value, places)
480495
sign = '-' if sgn < 0.0 else ''
481-
return format(sign, h, m, s, fraction, places)
496+
return fmt(sign, h, m, s, fraction, places)
482497

483498
def wms(whole, minutes=0.0, seconds=0.0):
484499
"""Return a quantity expressed with 1/60 minutes and 1/3600 seconds."""

0 commit comments

Comments
 (0)