Skip to content

Commit 915cb2b

Browse files
committed
ENH: Add linear interpolation of indices
Add the ability to linear interpolate between the 3-hourly and daily indices that are input. This enables a smoother sampling without noticeable step-changes when going from say 02:59 to 03:00.
1 parent 03381e2 commit 915cb2b

File tree

5 files changed

+236
-11
lines changed

5 files changed

+236
-11
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [v0.13.0] unreleased
6+
7+
- **ADDED** `interpolate_indices` option.
8+
- Linearly interpolate all input indices between their native time
9+
resolution (daily for F10.7/F10.7a/daily Ap, 3-hourly for ap indices).
10+
This can help avoid large step-changes in estimates when evaluating
11+
at higher temporal resolutions. There can be step changes of more than
12+
20% in some situations when going from hour 02:59 to 03:00 when new
13+
indices get used again on the 3-hourly boundaries.
14+
- **CHANGED** `get_f107_ap()` returns arrays of the same shape as the input
15+
- Previously, when a scalar date was passed to the utility function, the
16+
`ap` values were 1d (7,) rather than of shape (1, 7) corresponding to
17+
the date and all ap values. This makes the output consistent for scalar
18+
and array-like inputs.
19+
- This should have minimal impact on users, as it is a helper function
20+
and behavior of the calculation routines is unchanged.
21+
522
## [v0.12.0] 2025-11-26
623

724
- **MAINTENANCE** Publish Python 3.14 wheels.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Comparison of Step vs Interpolated Geomagnetic Indices
3+
=======================================================
4+
5+
This example demonstrates the difference between using step-function
6+
(discrete 3-hourly) and linearly interpolated geomagnetic indices
7+
when calculating atmospheric density with MSIS.
8+
9+
The step-function approach uses constant values within each 3-hour window,
10+
while the interpolated approach smoothly transitions between values.
11+
This can result in noticeable differences in computed density, especially
12+
for high-cadence simulations.
13+
14+
Note: This example uses storm-time Ap mode (geomagnetic_activity=-1) which
15+
uses the 3-hourly ap values. The default daily Ap mode (geomagnetic_activity=1)
16+
only uses daily average Ap values where interpolation has less effect.
17+
"""
18+
19+
from datetime import datetime, timedelta
20+
21+
import matplotlib.dates as mdates
22+
import matplotlib.pyplot as plt
23+
import numpy as np
24+
25+
import pymsis
26+
from pymsis.utils import get_f107_ap
27+
28+
29+
# %%
30+
# Set up the simulation
31+
# ---------------------
32+
# We'll compute density at a single mid-latitude location over 24 hours.
33+
34+
lat, lon, altitude = 45, -100, 400 # Mid-latitude, LEO altitude
35+
start_date = datetime(2024, 5, 10, 0, 0, 0)
36+
37+
# 1-minute sampling for 24 hours
38+
dates = np.array(
39+
[np.datetime64(start_date) + np.timedelta64(i, "m") for i in range(1440)]
40+
)
41+
42+
# %%
43+
# Calculate densities and get indices
44+
# -----------------------------------
45+
46+
# Use storm-time mode (geomagnetic_activity=-1) to use 3-hourly ap values
47+
output_step = pymsis.calculate(
48+
dates, lon, lat, altitude, geomagnetic_activity=-1, interpolate_indices=False
49+
)
50+
output_interp = pymsis.calculate(
51+
dates, lon, lat, altitude, geomagnetic_activity=-1, interpolate_indices=True
52+
)
53+
54+
density_step = output_step[..., pymsis.Variable.MASS_DENSITY].squeeze()
55+
density_interp = output_interp[..., pymsis.Variable.MASS_DENSITY].squeeze()
56+
57+
# Get the underlying indices
58+
_, _, ap_step = get_f107_ap(dates)
59+
_, _, ap_interp = get_f107_ap(dates, interpolate=True)
60+
61+
# %%
62+
# Plot the comparison
63+
# -------------------
64+
65+
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True, layout="constrained")
66+
plot_dates = [d.astype("datetime64[ms]").astype(datetime) for d in dates]
67+
68+
# Panel 1: 3-hourly ap index
69+
ax = axes[0]
70+
ax.plot(plot_dates, ap_step[:, 1], "b-", label="Step", linewidth=1)
71+
ax.plot(plot_dates, ap_interp[:, 1], "r--", label="Interpolated", linewidth=1)
72+
ax.set_ylabel("3-hour ap")
73+
ax.set_title("Geomagnetic Index (ap)")
74+
ax.legend(loc="upper left")
75+
ax.grid(True, alpha=0.3)
76+
77+
# Panel 2: Mass density
78+
ax = axes[1]
79+
ax.plot(plot_dates, density_step, "b-", label="Step", linewidth=1)
80+
ax.plot(plot_dates, density_interp, "r--", label="Interpolated", linewidth=1)
81+
ax.set_ylabel("Density (kg/m³)")
82+
ax.set_title(f"Mass Density at {altitude} km")
83+
ax.legend(loc="upper left")
84+
ax.grid(True, alpha=0.3)
85+
86+
# Panel 3: Percent difference
87+
ax = axes[2]
88+
pct_diff = 100 * (density_interp - density_step) / density_step
89+
ax.plot(plot_dates, pct_diff, "g-", linewidth=1)
90+
ax.axhline(0, color="black", linestyle="-", alpha=0.3)
91+
ax.set_ylabel("Difference (%)")
92+
ax.set_xlabel("Time (UTC)")
93+
ax.set_title("Relative Difference (Interpolated - Step) / Step")
94+
ax.grid(True, alpha=0.3)
95+
96+
# Format x-axis
97+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
98+
ax.xaxis.set_major_locator(mdates.HourLocator(interval=3))
99+
ax.set_xlim(plot_dates[0], plot_dates[-1])
100+
101+
# Mark 3-hour boundaries on all panels
102+
for ax in axes:
103+
for hour in range(0, 25, 3):
104+
boundary = start_date + timedelta(hours=hour)
105+
ax.axvline(boundary, color="gray", linestyle=":", alpha=0.5)
106+
107+
plt.show()

pymsis/msis.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def calculate(
8181
*,
8282
options: list[float] | None = None,
8383
version: float | str = 2.1,
84+
interpolate_indices: bool = False,
8485
**kwargs: dict,
8586
) -> npt.NDArray:
8687
r"""
@@ -140,6 +141,12 @@ def calculate(
140141
all keyword arguments specifying individual options will be ignored.
141142
version : Number or string, default: 2.1
142143
MSIS version number, one of (0, 2.0, 2.1).
144+
interpolate_indices : bool, default: False
145+
If True, linearly interpolate F10.7, F10.7a, and ap indices between
146+
their native time resolution (daily for F10.7/F10.7a, 3-hourly for ap).
147+
If False (default), use step-function sampling where values change
148+
discretely at boundaries. Linear interpolation can provide smoother
149+
density variations for high-cadence simulations.
143150
**kwargs : dict
144151
Single options for the switches can be defined through keyword arguments.
145152
For example, ``calculate(..., geomagnetic_activity=-1)`` will set the
@@ -227,6 +234,12 @@ def calculate(
227234
Terdiurnal (8-hour) atmospheric variations. Controls atmospheric tides
228235
that occur three times per day due to solar heating harmonics. Setting
229236
to 0 removes these 8-hourly atmospheric oscillations.
237+
interpolate_indices : bool, default: False
238+
If True, linearly interpolate F10.7, F10.7a, and ap indices between
239+
their native time resolution (daily for F10.7/F10.7a, 3-hourly for ap).
240+
If False (default), use step-function sampling where values change
241+
discretely at boundaries. Linear interpolation can provide smoother
242+
density variations for high-cadence simulations.
230243
231244
Notes
232245
-----
@@ -241,7 +254,16 @@ def calculate(
241254
elif len(options) != num_options:
242255
raise ValueError(f"options needs to be a list of length {num_options}")
243256

244-
input_shape, input_data = create_input(dates, lons, lats, alts, f107s, f107as, aps)
257+
input_shape, input_data = create_input(
258+
dates,
259+
lons,
260+
lats,
261+
alts,
262+
f107s,
263+
f107as,
264+
aps,
265+
interpolate_indices=interpolate_indices,
266+
)
245267

246268
if np.any(~np.isfinite(input_data)):
247269
raise ValueError(
@@ -422,6 +444,7 @@ def create_input(
422444
f107s: npt.ArrayLike | None = None,
423445
f107as: npt.ArrayLike | None = None,
424446
aps: npt.ArrayLike | None = None,
447+
interpolate_indices: bool = False,
425448
) -> tuple[tuple, npt.NDArray]:
426449
"""
427450
Combine all input values into a single flattened array.
@@ -442,6 +465,9 @@ def create_input(
442465
F107 running 81-day average for the given date(s)
443466
aps : ArrayLike, optional
444467
Ap for the given date(s)
468+
interpolate_indices : bool, default: False
469+
If True, use linear interpolation for F10.7, F10.7a, and ap indices.
470+
If False, use step-function (nearest-neighbor) sampling.
445471
446472
Returns
447473
-------
@@ -471,7 +497,7 @@ def create_input(
471497
# If any of the geomagnetic data wasn't specified, we will default
472498
# to getting it with the utility functions.
473499
if f107s is None or f107as is None or aps is None:
474-
data = get_f107_ap(dates_arr)
500+
data = get_f107_ap(dates_arr, interpolate=interpolate_indices)
475501
# Only update the values that were None
476502
if f107s is None:
477503
f107s = data[0]

pymsis/utils.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ def _load_f107_ap_data() -> dict[str, npt.NDArray]:
172172
return data
173173

174174

175-
def get_f107_ap(dates: npt.ArrayLike) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
175+
def get_f107_ap(
176+
dates: npt.ArrayLike,
177+
interpolate: bool = False,
178+
) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
176179
"""
177180
Retrieve the F10.7 and ap data needed to run msis for the given times.
178181
@@ -183,6 +186,11 @@ def get_f107_ap(dates: npt.ArrayLike) -> tuple[npt.NDArray, npt.NDArray, npt.NDA
183186
----------
184187
dates : datetime-like or sequence of datetimes
185188
times of interest to get the proper ap and F10.7 values for
189+
interpolate : bool, default: False
190+
If True, linearly interpolate all values between their native time
191+
resolution (daily for F10.7/F10.7a/daily Ap, 3-hourly for ap indices).
192+
If False (default), use step-function sampling where values change
193+
discretely at boundaries.
186194
187195
Returns
188196
-------
@@ -211,7 +219,8 @@ def get_f107_ap(dates: npt.ArrayLike) -> tuple[npt.NDArray, npt.NDArray, npt.NDA
211219

212220
data_start = data["dates"][0]
213221
data_end = data["dates"][-1]
214-
date_offsets = dates - data_start
222+
date_offsets = np.atleast_1d(dates - data_start)
223+
# Ensure consistent array shapes for both scalar and array inputs
215224
# daily index values
216225
daily_indices = date_offsets.astype("timedelta64[D]").astype(int)
217226
# 3-hourly index values
@@ -225,10 +234,39 @@ def get_f107_ap(dates: npt.ArrayLike) -> tuple[npt.NDArray, npt.NDArray, npt.NDA
225234
f"{data_end}."
226235
)
227236

228-
f107 = np.take(data["f107"], daily_indices)
229-
f107a = np.take(data["f107a"], daily_indices)
230-
ap = np.take(data["ap"], ap_indices, axis=0)
231-
warn_or_not = np.take(data["warn_data"], daily_indices)
237+
if not interpolate:
238+
# Normal sampling (step function behavior)
239+
f107 = data["f107"][daily_indices]
240+
f107a = data["f107a"][daily_indices]
241+
ap = data["ap"][ap_indices]
242+
else:
243+
# Linear interpolation between time boundaries
244+
fractional_hours = date_offsets / np.timedelta64(1, "h")
245+
daily_frac = (fractional_hours / 24.0) % 1.0
246+
ap_frac = (fractional_hours % 3) / 3.0
247+
248+
def interp(
249+
arr: npt.NDArray, idx: npt.NDArray, frac: npt.NDArray
250+
) -> npt.NDArray:
251+
"""Linear interpolation with automatic bounds clipping."""
252+
floor_vals = np.take(arr, idx, axis=0, mode="clip")
253+
ceil_vals = np.take(arr, idx + 1, axis=0, mode="clip")
254+
# Expand frac for broadcasting with 2D arrays (ap data)
255+
if arr.ndim > frac.ndim:
256+
frac = frac[:, np.newaxis]
257+
return floor_vals + frac * (ceil_vals - floor_vals)
258+
259+
# Interpolate daily values (F10.7, F10.7a)
260+
f107 = interp(data["f107"], daily_indices, daily_frac)
261+
f107a = interp(data["f107a"], daily_indices, daily_frac)
262+
263+
# Interpolate 3-hourly ap values (all columns)
264+
ap = interp(data["ap"], ap_indices, ap_frac)
265+
266+
# Column 0 needs daily interpolation, use every 8th 3-hourly value
267+
ap[:, 0] = interp(data["ap"][::8, 0], daily_indices, daily_frac)
268+
269+
warn_or_not = data["warn_data"][daily_indices]
232270
# TODO: Do we want to warn if any values within 81 days of a point are used?
233271
# i.e. if any of the f107a values were interpolated or predicted
234272
if np.any(warn_or_not):

tests/test_utils.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ def test_loading_data(monkeypatch, tmp_path):
6363
np.datetime64("2000-01-01T00:00"),
6464
[np.nan],
6565
[166.2],
66-
[30, 56, np.nan, np.nan, np.nan, np.nan, np.nan],
66+
[[30, 56, np.nan, np.nan, np.nan, np.nan, np.nan]],
6767
),
6868
# Middle of the data file, should be fully filled
6969
(
7070
np.datetime64("2000-07-01T12:00"),
7171
[159.6],
7272
[186.3],
73-
[7, 4, 5, 9, 4, 5.25, 5.75],
73+
[[7, 4, 5, 9, 4, 5.25, 5.75]],
7474
),
7575
# Requesting two dates should return length two arrays
7676
(
@@ -85,7 +85,7 @@ def test_loading_data(monkeypatch, tmp_path):
8585
np.datetime64("2000-12-30T12:00"),
8686
[173.7],
8787
[173.5],
88-
[3, 4, 4, 3, 3, 6.375, 5.375],
88+
[[3, 4, 4, 3, 3, 6.375, 5.375]],
8989
),
9090
],
9191
)
@@ -130,3 +130,40 @@ def test_get_f107_ap_interpolated_warns(dates):
130130
UserWarning, match="There is data that was either interpolated or"
131131
):
132132
utils.get_f107_ap(dates)
133+
134+
135+
@pytest.mark.parametrize(
136+
("date", "expected_f107", "expected_ap_col1"),
137+
[
138+
# At exact boundary (00:00), interpolated should match step values
139+
([np.datetime64("2000-07-01T00:00")], 159.6, 9.0),
140+
# At 3-hour boundary: ap should match, f107 interpolates within day
141+
([np.datetime64("2000-07-01T03:00")], 160.1125, 4.0),
142+
# Mid-way through 3-hour window (01:30): both ap and f107 interpolate
143+
([np.datetime64("2000-07-01T01:30")], 159.85625, 6.5),
144+
],
145+
)
146+
def test_get_f107_ap_interpolate(date, expected_f107, expected_ap_col1):
147+
"""Test linear interpolation of F10.7 and ap values."""
148+
f107, _, ap = utils.get_f107_ap(date, interpolate=True)
149+
assert_allclose(f107[0], expected_f107)
150+
assert_allclose(ap[0, 1], expected_ap_col1)
151+
152+
153+
@pytest.mark.parametrize(
154+
"date",
155+
[
156+
np.datetime64("2000-01-01T00:00"),
157+
np.datetime64("2000-07-01T00:00"),
158+
np.datetime64("2000-07-02T00:00"),
159+
],
160+
)
161+
def test_get_f107_ap_interpolate_exact_match(date):
162+
"""Test that interpolation at exact data points returns the same as no interp."""
163+
f107_nointerp, f107a_nointerp, ap_nointerp = utils.get_f107_ap(
164+
date, interpolate=False
165+
)
166+
f107_interp, f107a_interp, ap_interp = utils.get_f107_ap(date, interpolate=True)
167+
assert_allclose(f107_nointerp, f107_interp)
168+
assert_allclose(f107a_nointerp, f107a_interp)
169+
assert_array_equal(ap_nointerp, ap_interp)

0 commit comments

Comments
 (0)