Skip to content

Commit e829b30

Browse files
Merge pull request #1074 from RocketPy-Team/bug/pre-release-v1.13.0-review-fixes
BUG/MNT: pre-release v1.13.0 review fixes
1 parent 10fa378 commit e829b30

12 files changed

Lines changed: 212 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35-
- ENH: MNT: pre-release v1.13.0 cleanup — changelog consolidation + optional deps sync [#1073](https://github.com/RocketPy-Team/RocketPy/pull/1073)
3635
### Changed
3736

3837
### Fixed
@@ -72,8 +71,10 @@ Attention: The newest changes should be on top -->
7271
- MNT: Remove unused pylint disable statements [#1067](https://github.com/RocketPy-Team/RocketPy/pull/1067)
7372
- MNT: Discrete controllers are now called exactly once per time node (previously twice); results may change for stateful controllers and `observed_variables` no longer contains duplicated entries [#949](https://github.com/RocketPy-Team/RocketPy/pull/949)
7473
- MNT: Multi-dimensional linear `Function` objects now apply their extrapolation rule to points outside the data's convex hull (previously returned NaN) [#969](https://github.com/RocketPy-Team/RocketPy/pull/969)
75-
- MNT: Informational messages previously shown with `print()` now use Python logging and are silent by default; call `rocketpy.utils.enable_logging()` to see them [#973](https://github.com/RocketPy-Team/RocketPy/pull/973)
74+
- MNT: Informational messages previously shown with `print()` now use Python logging and are silent by default; call `rocketpy.utilities.enable_logging()` to see them [#973](https://github.com/RocketPy-Team/RocketPy/pull/973)
7675
- ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055)
76+
- MNT: `Function` with `interpolation="akima"` now matches the canonical (SciPy) Akima spline; interpolated values differ slightly from previous releases [#1049](https://github.com/RocketPy-Team/RocketPy/pull/1049)
77+
- MNT: `Function.differentiate` on 1-D array Functions now returns the analytical derivative of the interpolation (previously central finite differences); values at knots and domain edges may change [#1049](https://github.com/RocketPy-Team/RocketPy/pull/1049)
7778

7879
### Deprecated
7980

docs/examples/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ apogee of some rockets.
1212
:hide-code:
1313

1414
import plotly.graph_objects as go
15+
import plotly.io as pio
16+
17+
# Emit an HTML output that pulls plotly.js from the CDN, so the figure
18+
# renders on the static documentation pages. Without an explicit renderer,
19+
# ``fig.show()`` under jupyter-execute produces a plotly-mimetype bundle
20+
# that a static HTML page has no frontend to display (blank chart).
21+
pio.renderers.default = "notebook_connected"
1522

1623
results = {
1724
# "Name (Year)": (simulated, measured) m

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ monte-carlo = [
7373

7474
animation = [
7575
"pyvista>=0.45",
76+
"imageio",
7677
"imageio-ffmpeg>=0.5"
7778
]
7879

rocketpy/environment/environment.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
get_interval_date_from_time_array,
3939
get_pressure_levels_from_file,
4040
mask_and_clean_dataset,
41+
pressure_unit_to_factor,
4142
)
4243
from rocketpy.environment.weather_model_mapping import WeatherModelMapping
4344
from rocketpy.mathutils.function import NUMERICAL_TYPES, Function, funcify_method
@@ -1158,9 +1159,7 @@ def __determine_pressure_conversion_factor(
11581159
if pressure_conversion_factor is not None:
11591160
# User explicitly supplied a value — honour it.
11601161
if isinstance(pressure_conversion_factor, str):
1161-
return (
1162-
100 if pressure_conversion_factor.lower() in ("mbar", "hpa") else 1
1163-
)
1162+
return pressure_unit_to_factor(pressure_conversion_factor)
11641163
return pressure_conversion_factor
11651164

11661165
# Auto-detect. Primary source: known-model lookup table.
@@ -1173,9 +1172,9 @@ def __determine_pressure_conversion_factor(
11731172
_pa_files = {"GFS", "NAM", "RAP", "HRRR", "AIGFS"}
11741173
if input_dict in _hpa_dicts or input_file in _hpa_dicts:
11751174
return 100
1176-
if input_file in _hpa_files:
1175+
if input_dict in _hpa_files or input_file in _hpa_files:
11771176
return 100
1178-
if input_file in _pa_files:
1177+
if input_dict in _pa_files or input_file in _pa_files:
11791178
return 1
11801179
return None
11811180

@@ -1360,11 +1359,7 @@ def set_atmospheric_model( # pylint: disable=too-many-statements
13601359
"Argument 'pressure_conversion_factor' must be strictly positive!"
13611360
)
13621361
if isinstance(pressure_conversion_factor, str):
1363-
if pressure_conversion_factor.lower() not in (
1364-
"mbar",
1365-
"hpa",
1366-
"pa",
1367-
):
1362+
if pressure_unit_to_factor(pressure_conversion_factor) is None:
13681363
raise ValueError(
13691364
"Argument 'pressure_conversion_factor' unit must be a standard pressure unit ('mbar', 'hPa', 'Pa')!"
13701365
)
@@ -2961,6 +2956,7 @@ def to_dict(self, **kwargs):
29612956
"wind_direction_ensemble": getattr(self, "wind_direction_ensemble", None),
29622957
"wind_speed_ensemble": getattr(self, "wind_speed_ensemble", None),
29632958
"num_ensemble_members": getattr(self, "num_ensemble_members", None),
2959+
"ensemble_member": getattr(self, "ensemble_member", None),
29642960
}
29652961

29662962
if kwargs.get("include_outputs", False):
@@ -3027,6 +3023,7 @@ def from_dict(cls, data): # pylint: disable=too-many-statements
30273023
env.wind_direction_ensemble = data["wind_direction_ensemble"]
30283024
env.wind_speed_ensemble = data["wind_speed_ensemble"]
30293025
env.num_ensemble_members = data["num_ensemble_members"]
3026+
env.ensemble_member = data.get("ensemble_member", 0) or 0
30303027

30313028
env.__reset_barometric_height_function()
30323029
env.calculate_density_profile()

rocketpy/environment/tools.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,35 @@ def geodesic_to_lambert_conformal(lat, lon, projection_variable, x_units="m"):
190190
## These functions are meant to be used with netcdf4 datasets
191191

192192

193+
HPA_UNIT_SYNONYMS = frozenset(
194+
{"hpa", "mbar", "mb", "millibar", "millibars", "hectopascal", "hectopascals"}
195+
)
196+
PA_UNIT_SYNONYMS = frozenset({"pa", "pascal"})
197+
198+
199+
def pressure_unit_to_factor(unit):
200+
"""Return the Pa conversion factor for a pressure-unit string.
201+
202+
Parameters
203+
----------
204+
unit : str
205+
Pressure unit label (case-insensitive), e.g. ``"hPa"``, ``"mb"``,
206+
``"millibar"`` or ``"Pa"``.
207+
208+
Returns
209+
-------
210+
int or None
211+
``100`` for hPa/millibar synonyms, ``1`` for Pa synonyms, or ``None``
212+
if the unit string is not recognised.
213+
"""
214+
unit = unit.lower().strip()
215+
if unit in HPA_UNIT_SYNONYMS:
216+
return 100
217+
if unit in PA_UNIT_SYNONYMS:
218+
return 1
219+
return None
220+
221+
193222
def get_pressure_levels_from_file(data, dictionary, conversion_factor):
194223
"""Extracts pressure levels from a netCDF4 dataset and converts them to Pa.
195224
@@ -219,7 +248,7 @@ def get_pressure_levels_from_file(data, dictionary, conversion_factor):
219248
level_var = data.variables[dictionary["level"]]
220249
if conversion_factor is None:
221250
raw_units = getattr(level_var, "units", "").lower().strip()
222-
if raw_units in ("hpa", "mbar", "millibars", "hectopascal", "hectopascals"):
251+
if raw_units in HPA_UNIT_SYNONYMS:
223252
conversion_factor = 100
224253
else:
225254
conversion_factor = 1

rocketpy/mathutils/_calc/_fitting.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ def fit_akima(x, y):
100100
h = np.diff(x)
101101
m = np.diff(y) / h # slopes of segments, shape (n-1,)
102102

103+
if n == 2:
104+
# A single segment: Akima reduces to linear interpolation. The ghost-
105+
# value reflection below indexes ``m[1]``/``m[-2]``, which do not exist
106+
# for two points, so this case must be handled explicitly.
107+
return np.vstack([y[:-1], m, np.zeros(1), np.zeros(1)])
108+
103109
# Extend m with 2 ghost values on each side (Akima boundary reflection)
104110
# m_ext indices: 0..n+2 (n-1 interior + 2 left + 2 right)
105111
m_ext = np.empty(n + 3)
@@ -176,12 +182,15 @@ def fit_pchip(x, y):
176182
sign_change = (m[:-1] * m[1:]) <= 0
177183
pos_mask = ~sign_change
178184

179-
# Safe harmonic mean
180-
t[1:-1] = np.where(
181-
pos_mask,
182-
(w1 + w2) / (w1 / m[:-1] + w2 / m[1:]),
183-
0.0,
184-
)
185+
# Safe harmonic mean. The reciprocals are evaluated eagerly before
186+
# ``np.where`` masks them, so silence the harmless divide-by-zero that
187+
# occurs on flat segments (where ``pos_mask`` is already False).
188+
with np.errstate(divide="ignore", invalid="ignore"):
189+
t[1:-1] = np.where(
190+
pos_mask,
191+
(w1 + w2) / (w1 / m[:-1] + w2 / m[1:]),
192+
0.0,
193+
)
185194

186195
# Boundary slopes: one-sided, clamped for monotonicity
187196
t[0] = _pchip_edge_slope(

rocketpy/motors/ring_cluster_motor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,9 @@ def info(self, *args, **kwargs):
289289
print("Cluster Configuration:")
290290
print(f" - Motors: {self.number} x {type(self.motor).__name__}")
291291
print(f" - Radial Distance: {self.radius} m")
292-
return self.motor.info(*args, **kwargs)
292+
# Report the cluster's own (N-times scaled) quantities, not the single
293+
# base motor's, mirroring the correct behaviour of ``all_info``.
294+
return super().info(*args, **kwargs)
293295

294296
def to_dict(self, **kwargs):
295297
data = super().to_dict(**kwargs)

rocketpy/plots/flight_plots.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -888,10 +888,7 @@ def _animation_options( # pylint: disable=too-many-statements
888888
options = {
889889
"background_color": kwargs.pop("background_color", None),
890890
"playback_controls": kwargs.pop("playback_controls", True),
891-
"show_subrocket_point": kwargs.pop(
892-
"show_subrocket_point",
893-
kwargs.pop("show_subrocket_point", True),
894-
),
891+
"show_subrocket_point": kwargs.pop("show_subrocket_point", True),
895892
"ground_image": kwargs.pop("ground_image", None),
896893
"ground_image_bounds": kwargs.pop("ground_image_bounds", None),
897894
"ground_image_coordinates": kwargs.pop("ground_image_coordinates", "enu"),

rocketpy/rocket/parachute.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from inspect import signature
1+
from inspect import Parameter, signature
22

33
import numpy as np
44

@@ -302,6 +302,10 @@ def __evaluate_trigger_function(self, trigger): # pylint: disable=too-many-stat
302302
def _make_wrapper(fn):
303303
sig = signature(fn)
304304
params = list(sig.parameters.keys())
305+
has_var_positional = any(
306+
param.kind is Parameter.VAR_POSITIONAL
307+
for param in sig.parameters.values()
308+
)
305309

306310
# detect if user function expects acceleration-like argument
307311
expects_udot = any(
@@ -324,6 +328,12 @@ def wrapper(p, h, y, sensors, u_dot):
324328
if num_params >= 5:
325329
# Pass both sensors and u_dot
326330
return fn(p, h, y, sensors, u_dot)
331+
if has_var_positional:
332+
# Variadic triggers (e.g. ``lambda *args: ...``) accept any
333+
# number of positional arguments. Preserve the pre-1.13
334+
# behaviour of calling them with (pressure, height, state,
335+
# sensors).
336+
return fn(p, h, y, sensors)
327337
# If function signature is not supported, raise an error
328338
raise TypeError(
329339
f"Trigger function '{fn.__name__}' has unsupported signature: "

tests/unit/environment/test_environment.py

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
geodesic_to_utm,
1515
get_final_date_from_time_array,
1616
get_initial_date_from_time_array,
17+
get_pressure_levels_from_file,
18+
pressure_unit_to_factor,
1719
utm_to_geodesic,
1820
)
1921
from rocketpy.environment.weather_model_mapping import WeatherModelMapping
@@ -378,14 +380,21 @@ def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata(
378380
ensemble_metadata.update(
379381
{
380382
"level_ensemble": np.array([1000.0, 900.0]),
381-
"height_ensemble": np.array([[0.0, 1000.0]]),
382-
"temperature_ensemble": np.array([[288.15, 281.15]]),
383-
"wind_u_ensemble": np.array([[2.0, 3.0]]),
384-
"wind_v_ensemble": np.array([[4.0, 5.0]]),
385-
"wind_heading_ensemble": np.array([[26.565051, 30.963757]]),
386-
"wind_direction_ensemble": np.array([[206.565051, 210.963757]]),
387-
"wind_speed_ensemble": np.array([[4.472136, 5.830952]]),
388-
"num_ensemble_members": 1,
383+
"height_ensemble": np.array([[0.0, 1000.0], [0.0, 1000.0]]),
384+
"temperature_ensemble": np.array([[288.15, 281.15], [288.15, 281.15]]),
385+
"wind_u_ensemble": np.array([[2.0, 3.0], [2.0, 3.0]]),
386+
"wind_v_ensemble": np.array([[4.0, 5.0], [4.0, 5.0]]),
387+
"wind_heading_ensemble": np.array(
388+
[[26.565051, 30.963757], [26.565051, 30.963757]]
389+
),
390+
"wind_direction_ensemble": np.array(
391+
[[206.565051, 210.963757], [206.565051, 210.963757]]
392+
),
393+
"wind_speed_ensemble": np.array(
394+
[[4.472136, 5.830952], [4.472136, 5.830952]]
395+
),
396+
"num_ensemble_members": 2,
397+
"ensemble_member": 1,
389398
}
390399
)
391400

@@ -416,6 +425,7 @@ def test_environment_to_dict_from_dict_round_trip_preserves_weather_metadata(
416425
npt.assert_allclose(restored_env.level_ensemble, env.level_ensemble)
417426
npt.assert_allclose(restored_env.height_ensemble, env.height_ensemble)
418427
assert restored_env.num_ensemble_members == env.num_ensemble_members
428+
assert restored_env.ensemble_member == env.ensemble_member == 1
419429

420430

421431
class _DummyDataset:
@@ -818,3 +828,91 @@ def test_pressure_conversion_factor_autodetect_by_model(
818828
None, None, model
819829
)
820830
assert factor == expected_factor
831+
832+
833+
@pytest.mark.parametrize(
834+
"model, expected_factor",
835+
[("GEFS", 100), ("HIRESW", 100), ("GFS", 1), ("AIGFS", 1)],
836+
)
837+
def test_pressure_conversion_factor_autodetect_by_dictionary(
838+
example_plain_env, model, expected_factor
839+
):
840+
"""Model shortcuts arriving via ``dictionary`` (the realistic download
841+
path) must map to the same factor as when they arrive via ``file``."""
842+
factor = example_plain_env._Environment__determine_pressure_conversion_factor(
843+
None, model, None
844+
)
845+
assert factor == expected_factor
846+
847+
848+
@pytest.mark.parametrize(
849+
"units, expected_levels",
850+
[
851+
("mb", [100000.0, 85000.0]),
852+
("millibar", [100000.0, 85000.0]),
853+
("millibars", [100000.0, 85000.0]),
854+
("hPa", [100000.0, 85000.0]),
855+
("mbar", [100000.0, 85000.0]),
856+
("Pa", [1000.0, 850.0]),
857+
],
858+
)
859+
def test_get_pressure_levels_from_file_unit_synonyms(units, expected_levels):
860+
"""hPa/millibar unit synonyms auto-scale by 100; Pa by 1."""
861+
862+
class _Var:
863+
def __init__(self, values, units):
864+
self._values = np.asarray(values)
865+
self.units = units
866+
867+
def __getitem__(self, key):
868+
return self._values[key]
869+
870+
class _DS:
871+
def __init__(self, var):
872+
self.variables = {"lev": var}
873+
874+
dataset = _DS(_Var([1000.0, 850.0], units))
875+
levels = get_pressure_levels_from_file(dataset, {"level": "lev"}, None)
876+
npt.assert_allclose(levels, expected_levels)
877+
878+
879+
@pytest.mark.parametrize(
880+
"unit, expected",
881+
[
882+
("mbar", 100),
883+
("mb", 100),
884+
("hPa", 100),
885+
("millibar", 100),
886+
("millibars", 100),
887+
("hectopascal", 100),
888+
("Pa", 1),
889+
("pascal", 1),
890+
("parsecs", None),
891+
("", None),
892+
],
893+
)
894+
def test_pressure_unit_to_factor(unit, expected):
895+
"""The shared unit->factor helper: hPa synonyms ->100, Pa ->1, else None."""
896+
897+
assert pressure_unit_to_factor(unit) == expected
898+
899+
900+
@pytest.mark.parametrize("unit, expected", [("mb", 100), ("millibar", 100), ("Pa", 1)])
901+
def test_pressure_conversion_factor_explicit_unit_synonyms(
902+
example_plain_env, unit, expected
903+
):
904+
"""An explicit string ``pressure_conversion_factor`` accepts the same unit
905+
synonyms as file auto-detection (Copilot review consistency fix)."""
906+
factor = example_plain_env._Environment__determine_pressure_conversion_factor(
907+
unit, None, None
908+
)
909+
assert factor == expected
910+
911+
912+
def test_set_atmospheric_model_rejects_unknown_pressure_unit(example_plain_env):
913+
"""An unrecognized ``pressure_conversion_factor`` unit is rejected during
914+
validation, before any file access."""
915+
with pytest.raises(ValueError, match="pressure_conversion_factor"):
916+
example_plain_env.set_atmospheric_model(
917+
type="Forecast", file="dummy", pressure_conversion_factor="parsecs"
918+
)

0 commit comments

Comments
 (0)