From 3dc4ee7e7750536900d6a00aeaeeb2e2b7b8f850 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:31:40 +0800 Subject: [PATCH] BUG: apply wind factors to the ensemble member that was selected select_ensemble_member() rebuilds the wind functions from the chosen member's own profile, and create_object() reached it after the factors because that is where __dict__ happened to put it. The factor was scaled into the previous member's wind and then thrown away, so the run flew the raw member wind while the input record still reported a factor. Factors are now applied once the loop is done, so the order of __dict__ stops mattering, and the baseline is the member just loaded rather than the value cached at construction. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- rocketpy/stochastic/stochastic_environment.py | 23 +++-- .../stochastic/test_stochastic_environment.py | 83 +++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/rocketpy/stochastic/stochastic_environment.py b/rocketpy/stochastic/stochastic_environment.py index 95845f51f..15fbe7c83 100644 --- a/rocketpy/stochastic/stochastic_environment.py +++ b/rocketpy/stochastic/stochastic_environment.py @@ -182,16 +182,29 @@ def create_object(self): member attribute. """ generated_dict = next(self.dict_generator()) + factors = {} + member_selected = False for key, value in generated_dict.items(): # special case for ensemble member # TODO: Generalize create_object() with a env.ensemble_member setter if key == "ensemble_member": self.obj.select_ensemble_member(value) + member_selected = True + elif "factor" in key: + factors[key.replace("_factor", "")] = value else: - if "factor" in key: - # get original attribute value and multiply by factor - attribute_name = f"_{key.replace('_factor', '')}" - value = getattr(self, attribute_name) * value - key = f"{key.replace('_factor', '')}" setattr(self.obj, key, value) + + # Applied last, and not where the loop met them: select_ensemble_member + # rebuilds the wind from the member's own profile, so a factor scaled in + # earlier is discarded. Which one runs first is only __dict__ order. + for attribute_name, factor in factors.items(): + if member_selected: + # The member just loaded is the baseline. The construction-time + # one belongs to whichever member was active back then. + baseline = getattr(self.obj, attribute_name) + else: + # Construction-time value, so repeated calls do not compound. + baseline = getattr(self, f"_{attribute_name}") + setattr(self.obj, attribute_name, baseline * factor) return self.obj diff --git a/tests/unit/stochastic/test_stochastic_environment.py b/tests/unit/stochastic/test_stochastic_environment.py index ce115fe05..80efca30a 100644 --- a/tests/unit/stochastic/test_stochastic_environment.py +++ b/tests/unit/stochastic/test_stochastic_environment.py @@ -1,4 +1,8 @@ +import numpy as np +import pytest + from rocketpy.environment.environment import Environment +from rocketpy.stochastic import StochasticEnvironment def test_str(stochastic_environment): @@ -41,3 +45,82 @@ class creates a StochasticEnvironment object from the randomly generated """ obj = stochastic_environment.create_object() assert isinstance(obj, Environment) + + +def _two_member_ensemble(first=10.0, second=30.0): + """An Environment with two ensemble members whose winds differ. + + Built here rather than read from a NetCDF file so the two winds are known + exactly and a factor applied to the wrong one is visible in the result. + """ + levels = np.array([100000.0, 90000.0, 80000.0]) + height = np.array([0.0, 1000.0, 2000.0]) + temperature = np.array([288.0, 282.0, 275.0]) + winds = (first, second) + + environment = Environment() + environment.set_atmospheric_model(type="custom_atmosphere", wind_u=0, wind_v=0) + environment.atmospheric_model_type = "Ensemble" + environment.num_ensemble_members = 2 + environment.level_ensemble = levels + environment.height_ensemble = np.tile(height, (2, 1)) + environment.temperature_ensemble = np.tile(temperature, (2, 1)) + environment.wind_u_ensemble = np.array([np.full(3, wind) for wind in winds]) + environment.wind_v_ensemble = np.zeros((2, 3)) + environment.wind_speed_ensemble = np.array([np.full(3, wind) for wind in winds]) + environment.wind_heading_ensemble = np.full((2, 3), 90.0) + environment.wind_direction_ensemble = np.full((2, 3), 270.0) + environment.ensemble_member = 0 + environment.select_ensemble_member(0) + return environment + + +def test_create_object_scales_the_wind_of_the_member_it_selected(): + """A wind factor must multiply the selected member's own wind. + + ``select_ensemble_member`` rebuilds the wind from that member's profile, so + a factor applied before it used to be discarded and the run flew the raw + member wind while the input record still reported the factor. + """ + environment = _two_member_ensemble(first=10.0, second=30.0) + stochastic = StochasticEnvironment( + environment=environment, + ensemble_member=[1], + wind_velocity_x_factor=(2.0, 0), + ) + stochastic._set_stochastic(7) + + wind = float(stochastic.create_object().wind_velocity_x(500)) + + assert wind == pytest.approx(60.0, rel=1e-9) + assert wind != pytest.approx(30.0, rel=1e-9) # factor dropped + assert wind != pytest.approx(20.0, rel=1e-9) # member 0's cached wind + + +def test_create_object_does_not_compound_the_factor_across_calls(): + """Each call scales the member's profile once, not the previous result.""" + environment = _two_member_ensemble(first=10.0, second=30.0) + stochastic = StochasticEnvironment( + environment=environment, + ensemble_member=[1], + wind_velocity_x_factor=(2.0, 0), + ) + stochastic._set_stochastic(7) + + winds = [float(stochastic.create_object().wind_velocity_x(500)) for _ in range(3)] + + assert winds == pytest.approx([60.0, 60.0, 60.0], rel=1e-9) + + +def test_create_object_without_a_member_still_scales_the_construction_value(): + """Without ensemble members the factor keeps multiplying the original wind.""" + environment = Environment() + environment.set_atmospheric_model(type="custom_atmosphere", wind_u=10, wind_v=0) + stochastic = StochasticEnvironment( + environment=environment, wind_velocity_x_factor=(2.0, 0) + ) + stochastic._set_stochastic(7) + + winds = [float(stochastic.create_object().wind_velocity_x(500)) for _ in range(3)] + + assert winds == pytest.approx([20.0, 20.0, 20.0], rel=1e-9)