Skip to content

Commit 32e4e0a

Browse files
AdamRJensenkandersolarIoannisSifnaios
authored
Add refraction_correction option to nasa_horizons solar position algorithm (#133)
* Add adminition for nasa_horizons * Correct admonition formatting * Fix indentation * Add refraction_correction option to nasa_horizons solar position algorithm * (auto) Paper PDF Draft * Fix doc warning * Fix rendering Co-authored-by: Kevin Anderson <[email protected]> * Apply suggestions from code review Co-authored-by: Ioannis Sifnaios <[email protected]> * Add refraction algorithm note and reference * (auto) Paper PDF Draft --------- Co-authored-by: AdamRJensen <[email protected]> Co-authored-by: Kevin Anderson <[email protected]> Co-authored-by: Ioannis Sifnaios <[email protected]>
1 parent a866c54 commit 32e4e0a

File tree

3 files changed

+109
-39
lines changed

3 files changed

+109
-39
lines changed

paper/paper.pdf

0 Bytes
Binary file not shown.

src/solposx/solarposition/nasa_horizons.py

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22
import pandas as pd
33
import io
44

5-
URL = "https://ssd.jpl.nasa.gov/api/horizons.api"
5+
URL = 'https://ssd.jpl.nasa.gov/api/horizons.api'
66

77

8-
def nasa_horizons(
9-
latitude, longitude, start, end, elevation=0.0, *, time_step="1h", url=URL
10-
):
8+
def nasa_horizons(latitude, longitude, start, end, elevation=0., *,
9+
time_step='1h', refraction_correction=False, url=URL):
1110
"""
1211
Retrieve solar positions from NASA's Horizons web service.
1312
@@ -42,30 +41,39 @@ def nasa_horizons(
4241
Time step size of the requested time series. '1m' for minutes,
4342
'1h' for hours, '1d' for days, '1mo' for months, and '1y' for years.
4443
The default is '1h'.
44+
refraction_correction : bool, optional
45+
Whether to return apparent solar position angles corrected for
46+
atmospheric refraction. The defalt is False.
4547
url : str, optional
4648
API endpoint. The default is
4749
``'https://ssd.jpl.nasa.gov/api/horizons.api'``.
4850
4951
Returns
5052
-------
5153
pandas.DataFrame
52-
DataFrame with the following columns (all values in degrees):
54+
DataFrame with the following columns in degrees (note that all columns
55+
except azimuth are prefixed with `'apparent_'` if
56+
``refraction_correction=True``.)
5357
54-
- uncertainty_right_ascension
55-
- uncertainty_declination
5658
- right_ascension
5759
- declination
58-
- apparent_right_ascesion
59-
- apparent_declination
60-
- apparent_azimuth
61-
- apparent_elevation
60+
- azimuth
61+
- elevation
62+
63+
Notes
64+
-----
65+
The refraction correction algorithm is the same as
66+
:py:func:`solposx.refraction.spa`. See [3]_ for more
67+
information.
6268
6369
References
6470
----------
6571
.. [1] `NASA Horizons Systems
6672
<https://ssd.jpl.nasa.gov/horizons/>`_
6773
.. [2] `NASA Horizons API
6874
<https://ssd-api.jpl.nasa.gov/doc/horizons.html>`_
75+
.. [3] `NASA Horizons Manual
76+
<https://ssd.jpl.nasa.gov/horizons/manual.html>`_
6977
"""
7078
params = {
7179
"MAKE_EPHEM": "YES", # generate ephemeris
@@ -74,62 +82,75 @@ def nasa_horizons(
7482
"CENTER": "coord@399", # input coordinates for Earth (399)
7583
"COORD_TYPE": "GEODETIC", # latitude, longitude, elevation in degrees
7684
"SITE_COORD": f"{longitude},{latitude},{elevation/1000}",
77-
"START_TIME": pd.Timestamp(start).strftime("%Y-%m-%d %H:%M"),
78-
"STOP_TIME": pd.Timestamp(end).strftime("%Y-%m-%d %H:%M"),
85+
"START_TIME": pd.Timestamp(start).strftime('%Y-%m-%d %H:%M'),
86+
"STOP_TIME": pd.Timestamp(end).strftime('%Y-%m-%d %H:%M'),
7987
"STEP_SIZE": time_step,
8088
"QUANTITIES": "2,4", # corrected angles
8189
"REF_SYSTEM": "ICRF",
8290
"CAL_FORMAT": "CAL", # output date format
8391
"CAL_TYPE": "MIXED", # Gregorian or mixed Julian/Gregorian calendar
8492
"TIME_DIGITS": "FRACSEC", # output time precision
8593
"ANG_FORMAT": "DEG", # output angles in degrees
86-
"APPARENT": "AIRLESS", # no refraction
87-
# "RANGE_UNITS": "AU",
88-
# "SUPPRESS_RANGE_RATE": "NO",
8994
"SKIP_DAYLT": "NO", # include daylight periods
9095
"SOLAR_ELONG": "0,180",
9196
"EXTRA_PREC": "NO", # toggle additional digits on some angles (RA/DEC)
9297
"CSV_FORMAT": "NO",
93-
"OBJ_DATA": "NO", # whether to return summary data
98+
"OBJ_DATA": "NO" # whether to return summary data
9499
}
95100

101+
if refraction_correction:
102+
params["APPARENT"] = "REFRACTED" # account for refraction
103+
else:
104+
params["APPARENT"] = "AIRLESS" # do not account for refraction
105+
96106
# manual formatting of the url as all parameters except format shall be
97-
# in enclosed in single quotes
107+
# enclosed in single quotes
98108
url_formatted = (
99-
url + "?" + "format=text&" + "&".join([f"{k}='{v}'" for k, v in params.items()])
109+
url
110+
+ '?'
111+
+ "format=text&"
112+
+ '&'.join([f"{k}='{v}'" for k, v in params.items()])
100113
)
101114

102115
res = requests.get(url_formatted)
103116

104117
fbuf = io.StringIO(res.content.decode())
105118

106119
lines = fbuf.readlines()
107-
first_line = lines.index("$$SOE\n") + 1
108-
last_line = lines.index("$$EOE\n", first_line)
120+
first_line = lines.index('$$SOE\n') + 1
121+
last_line = lines.index('$$EOE\n', first_line)
109122
header_line = lines[first_line - 3]
110-
data_str = [header_line] + lines[first_line:last_line]
123+
data_str = [header_line] + lines[first_line: last_line]
111124

112-
data = pd.read_fwf(
113-
io.StringIO("\n".join(data_str)), index_col=[0], na_values=["n.a."]
114-
)
115-
data.index = pd.to_datetime(data.index, format="%Y-%b-%d %H:%M:%S.%f")
116-
data.index = data.index.tz_localize("UTC")
125+
data = pd.read_fwf(io.StringIO('\n'.join(data_str)),
126+
index_col=[0], na_values=['n.a.'])
127+
data.index = pd.to_datetime(data.index, format='%Y-%b-%d %H:%M:%S.%f')
128+
data.index = data.index.tz_localize('UTC')
117129

118130
# split columns as several params have a shared header name for two params
119-
column_name_split_map = {
120-
"R.A._(a-appar)_DEC.": ["right_ascension", "declination"],
121-
"Azi____(a-app)___Elev": ["azimuth", "elevation"],
122-
}
131+
if refraction_correction:
132+
column_name_split_map = {
133+
# "r" prefix stands for "refracted" aka. refracted/apparent angles
134+
'R.A._(r-appar)__DEC': ['apparent_right_ascension', 'apparent_declination'],
135+
'Azi____(r-app)___Elev': ['azimuth', 'apparent_elevation'],
136+
}
137+
138+
else:
139+
column_name_split_map = {
140+
# "a" prefix stands for "airless" aka. unrefracted
141+
'R.A._(a-appar)_DEC.': ['right_ascension', 'declination'],
142+
'Azi____(a-app)___Elev': ['azimuth', 'elevation'],
143+
}
123144

124145
for old_name, new_names in column_name_split_map.items():
125-
data[new_names] = data[old_name].str.split(r"\s+", expand=True).astype(float)
146+
data[new_names] = \
147+
data[old_name].str.split(r'\s+', expand=True).astype(float)
126148

127149
data = data.drop(columns=list(column_name_split_map.keys()))
128150

129-
data.index.name = "time"
151+
data.index.name = 'time'
130152
try:
131-
del data["Unnamed: 1"]
153+
del data['Unnamed: 1']
132154
except KeyError:
133155
pass
134-
135156
return data

tests/test_solarposition.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ def test_delta_t_series_input():
559559
pd.testing.assert_frame_equal(usno_array, usno_float)
560560

561561

562+
@pytest.fixture
562563
def expected_nasa():
563564
columns = ['right_ascension', 'declination', 'azimuth', 'elevation']
564565
values = [
@@ -595,17 +596,64 @@ def expected_nasa():
595596
return data
596597

597598

598-
def test_nasa_horizons():
599-
expected = expected_nasa()
599+
@pytest.fixture
600+
def expected_nasa_refracted():
601+
columns = ['apparent_right_ascension', 'apparent_declination', 'azimuth', 'apparent_elevation']
602+
values = [
603+
[280.73492, -22.42915, 18.384905, -61.460898],
604+
[280.59597, -22.49103, 43.990328, -56.428642],
605+
[280.54556, -22.54743, 63.159488, -48.660005],
606+
[280.55006, -22.57750, 77.862491, -39.580841],
607+
[280.58537, -22.58401, 90.093838, -30.008965],
608+
[280.64079, -22.57277, 101.147333, -20.430330],
609+
[280.71282, -22.54831, 111.849769, -11.199780],
610+
[280.80156, -22.51427, 122.773903, -2.643156],
611+
[281.15991, -22.87756, 134.336441, 4.426409],
612+
[281.26791, -22.95121, 146.804482, 10.455202],
613+
[281.33369, -22.96770, 160.234863, 14.733079],
614+
[281.39128, -22.97067, 174.399466, 16.846454],
615+
[281.44734, -22.96663, 188.799258, 16.581405],
616+
[281.50589, -22.95497, 202.838039, 13.965712],
617+
[281.57563, -22.92612, 216.067120, 9.260021],
618+
[281.71485, -22.80480, 228.325009, 2.936098],
619+
[282.04557, -22.49171, 239.722349, -4.450189],
620+
[282.13033, -22.51721, 250.562948, -13.182278],
621+
[282.19859, -22.53231, 261.295115, -22.514892],
622+
[282.24984, -22.53327, 272.536349, -32.120854],
623+
[282.27937, -22.51526, 285.194755, -41.626608],
624+
[282.27410, -22.47247, 300.704397, -50.493032],
625+
[282.20646, -22.40488, 321.186284, -57.787601],
626+
[282.04689, -22.34306, 348.166418, -61.945628],
627+
[281.84142, -22.34828, 18.119982, -61.406306],
628+
]
629+
data = pd.DataFrame(data=values, columns=columns)
630+
data.index = pd.date_range('2020-01-01', '2020-01-02', freq='1h', tz='UTC')
631+
data.index.name = 'time'
632+
data.index.freq = None
633+
return data
600634

635+
636+
def test_nasa_horizons(expected_nasa):
601637
result = nasa_horizons(
602638
latitude=50,
603639
longitude=10,
604640
start='2020-01-01',
605641
end='2020-01-02',
606642
)
607-
for c in expected.columns:
608-
pd.testing.assert_series_equal(expected[c], result[c])
643+
for c in expected_nasa.columns:
644+
pd.testing.assert_series_equal(expected_nasa[c], result[c])
645+
646+
647+
def test_nasa_horizons_refracted(expected_nasa_refracted):
648+
result = nasa_horizons(
649+
latitude=50,
650+
longitude=10,
651+
start='2020-01-01',
652+
end='2020-01-02',
653+
refraction_correction=True,
654+
)
655+
for c in expected_nasa_refracted.columns:
656+
pd.testing.assert_series_equal(expected_nasa_refracted[c], result[c])
609657

610658

611659
def test_nasa_horizons_frequency_daily():
@@ -634,6 +682,7 @@ def test_nasa_horizons_frequency_monthly():
634682
start=start,
635683
end=end,
636684
time_step='1mo', # monthly
685+
refraction_correction=True,
637686
)
638687
expected_index = pd.date_range(start, end, freq='MS', tz='UTC')
639688
expected_index.name = 'time'

0 commit comments

Comments
 (0)