Skip to content

Commit 5f88e10

Browse files
committed
[#1164] Fix Monte Carlo nested dispersion application
Apply Monte Carlo modifications through a nested path resolver instead of using setattr() on the full path string. Support dotted attributes, integer list indices, and existing zero-argument accessor paths such as get_DynModel().scObject.hub.r_CN_NInit without using eval or exec. Parse archived string values back into Python values before applying them, route saved RNGSeed updates through the same helper, and only record seed modifications for models that expose RNGSeed. Add focused unit coverage for nested attribute updates, method-call accessor paths, archived RNGSeed application before configureFunction, and generated UniformDispersion values reaching the live simulation object.
1 parent 92c6fb6 commit 5f88e10

3 files changed

Lines changed: 94 additions & 9 deletions

File tree

src/utilities/MonteCarlo/Controller.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -899,11 +899,11 @@ def __call__(cls, params):
899899
@staticmethod
900900
def _parseAttributePath(attributeString):
901901
"""
902-
Split a nested attribute path into attribute names and integer indices.
902+
Split a nested attribute path into accessors and integer indices.
903903
904904
:param attributeString: Attribute path to parse.
905905
:type attributeString: str
906-
:return: Parsed attribute names and list indices.
906+
:return: Parsed attribute names, zero-argument method calls, and list indices.
907907
:rtype: list
908908
"""
909909
if not isinstance(attributeString, str) or attributeString == "":
@@ -948,6 +948,31 @@ def _parseAttributePath(attributeString):
948948

949949
return pathParts
950950

951+
@staticmethod
952+
def _resolvePathPart(currentObj, pathPart):
953+
"""
954+
Resolve one intermediate Monte Carlo path part.
955+
956+
:param currentObj: Object where this path part starts.
957+
:param pathPart: Attribute name, zero-argument method call, or integer index.
958+
:return: The object resolved by ``pathPart``.
959+
"""
960+
if isinstance(pathPart, int):
961+
return currentObj[pathPart]
962+
963+
if pathPart.endswith("()"):
964+
methodName = pathPart[:-2]
965+
if not methodName.isidentifier():
966+
raise ValueError(
967+
"Only zero-argument method calls are supported in Monte Carlo paths"
968+
)
969+
method = getattr(currentObj, methodName)
970+
if not callable(method):
971+
raise ValueError("Monte Carlo path method part is not callable")
972+
return method()
973+
974+
return getattr(currentObj, pathPart)
975+
951976
@staticmethod
952977
def parseModificationValue(value):
953978
"""
@@ -981,14 +1006,13 @@ def setNestedAttr(cls, obj, attrString, value):
9811006
currentObj = obj
9821007

9831008
for pathPart in pathParts[:-1]:
984-
if isinstance(pathPart, int):
985-
currentObj = currentObj[pathPart]
986-
else:
987-
currentObj = getattr(currentObj, pathPart)
1009+
currentObj = cls._resolvePathPart(currentObj, pathPart)
9881010

9891011
finalPathPart = pathParts[-1]
9901012
if isinstance(finalPathPart, int):
9911013
currentObj[finalPathPart] = value
1014+
elif finalPathPart.endswith("()"):
1015+
raise ValueError("Monte Carlo paths cannot assign to method calls")
9921016
else:
9931017
setattr(currentObj, finalPathPart, value)
9941018

src/utilities/MonteCarlo/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ Statistical dispersions can be applied to initial parameters using the MonteCarl
5454
monteCarlo.addDispersion(UniformEulerAngleMRPDispersion("taskName.hub.sigma_BNInit"))
5555
```
5656

57-
Dispersion names can reference nested simulation attributes with dotted names and integer list
58-
indices. For example, `TaskList[0].TaskModels[0].hub.sigma_BNInit` updates the
59-
`sigma_BNInit` attribute on the first model's hub object.
57+
Dispersion names can reference nested simulation attributes with dotted names, integer list
58+
indices, and zero-argument accessor methods. For example,
59+
`TaskList[0].TaskModels[0].hub.sigma_BNInit` updates the `sigma_BNInit` attribute
60+
on the first model's hub object, while `get_DynModel().scObject.hub.r_CN_NInit`
61+
resolves the object returned by `get_DynModel()` before applying the dispersion.
6062

6163
If data is being retained, a archive directory to store retained data must be specified. This directory is later used to reload the retained data from an executed Monte Carlo simulation.
6264

src/utilities/MonteCarlo/_UnitTests/test_controller.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,19 @@ def __init__(self):
4646
self.values = [0.0, 0.0, 0.0]
4747

4848

49+
class DummyDynModel:
50+
def __init__(self):
51+
self.scObject = DummyModel()
52+
53+
4954
class DummySimulation:
5055
def __init__(self):
5156
self.TaskList = [DummyTask()]
5257
self.vectorContainer = DummyVectorContainer()
58+
self.dynModel = DummyDynModel()
59+
60+
def get_DynModel(self):
61+
return self.dynModel
5362

5463

5564
def test_apply_modification_updates_nested_attributes():
@@ -72,6 +81,12 @@ def test_apply_modification_updates_nested_attributes():
7281

7382
assert sim.vectorContainer.values == [0.0, 7.5, 0.0]
7483

84+
method_path = "get_DynModel().scObject.hub.mHub"
85+
SimulationExecutor.applyModification(sim, method_path, "35.0")
86+
87+
assert sim.get_DynModel().scObject.hub.mHub == 35.0
88+
assert not hasattr(sim, method_path)
89+
7590

7691
def test_populate_seeds_applies_before_configure_function():
7792
"""Verify archived RNGSeed modifications are set before configuration."""
@@ -153,6 +168,49 @@ def execute_sim(sim):
153168
assert observed_mass != DummyHub().mHub
154169

155170

171+
def test_uniform_dispersion_randomizes_method_path_parameter():
172+
"""Verify generated dispersions update a zero-argument method path."""
173+
mass_path = "get_DynModel().scObject.hub.mHub"
174+
mass_bounds = [34.0, 36.0] # [kg]
175+
observed_masses = []
176+
generated_masses = []
177+
178+
def create_sim():
179+
return DummySimulation()
180+
181+
def execute_sim(sim):
182+
observed_masses.append(sim.get_DynModel().scObject.hub.mHub)
183+
184+
for run_index in range(2):
185+
sim_params = SimulationParameters(
186+
creationFunction=create_sim,
187+
executionFunction=execute_sim,
188+
configureFunction=None,
189+
retentionPolicies=[],
190+
dispersions=[UniformDispersion(mass_path, mass_bounds)],
191+
shouldDisperseSeeds=False,
192+
shouldArchiveParameters=False,
193+
filename="",
194+
icfilename="",
195+
index=run_index,
196+
modifications={}
197+
)
198+
199+
result = SimulationExecutor()([sim_params, None])
200+
generated_mass = SimulationExecutor.parseModificationValue(
201+
sim_params.modifications[mass_path]
202+
)
203+
204+
assert result == (True, run_index)
205+
generated_masses.append(generated_mass)
206+
207+
assert observed_masses == generated_masses
208+
assert observed_masses[0] != observed_masses[1]
209+
for observed_mass in observed_masses:
210+
assert mass_bounds[0] <= observed_mass <= mass_bounds[1]
211+
assert observed_mass != DummyHub().mHub
212+
213+
156214
def test_disperse_seeds_only_records_seeded_models():
157215
"""Verify random seed dispersions are recorded only for seeded models."""
158216
sim = DummySimulation()
@@ -172,4 +230,5 @@ def test_disperse_seeds_only_records_seeded_models():
172230
test_apply_modification_updates_nested_attributes()
173231
test_populate_seeds_applies_before_configure_function()
174232
test_uniform_dispersion_randomizes_nested_sim_parameter()
233+
test_uniform_dispersion_randomizes_method_path_parameter()
175234
test_disperse_seeds_only_records_seeded_models()

0 commit comments

Comments
 (0)