Skip to content

Commit d698e1b

Browse files
authored
Merge branch 'develop' into fix-adaptive-data-check
2 parents 2002949 + 93735d2 commit d698e1b

10 files changed

Lines changed: 275 additions & 13 deletions

File tree

.github/workflows/run-unit-tests.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ jobs:
7171
cd tests/unit
7272
python3 -m unittest test_micro_manager.py
7373
74+
- name: Run model adaptivity unit tests
75+
working-directory: micro-manager
76+
run: |
77+
. .venv/bin/activate
78+
cd tests/unit
79+
python3 -m unittest test_model_adaptivity.py
80+
7481
- name: Install Micro Manager and run tasking unit test
7582
working-directory: micro-manager
7683
env:

CHANGELOG.md

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

33
## latest
44

5+
<<<<<<< fix-adaptive-data-check
56
- Allow `initialize()` to return data that is not used by the adaptivity [#261](https://github.com/precice/micro-manager/pull/261)
7+
=======
8+
- Fixed `MicroSimulation` initialization requiring positional parameters [#255](https://github.com/precice/micro-manager/pull/255)
9+
- Fixed model adaptivity convergence at resolution boundaries to prevent infinite loops for out-of-range switching requests [#252](https://github.com/precice/micro-manager/pull/252)
10+
- Add function `set_global_id` to the dummies and the example in the integration test [#247](https://github.com/precice/micro-manager/pull/247)
11+
>>>>>>> develop
612
713
## v0.9.0
814

docs/micro-simulation-convert-to-library.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ class MicroSimulation: # Name is fixed
6969
It will be called with frequency set by configuration option `simulation_params: micro_output_n`
7070
This function is *optional*.
7171
"""
72+
73+
def set_global_id(self, sim_id):
74+
"""
75+
Reset the global ID of the micro simulation.
76+
77+
Parameters
78+
----------
79+
sim_id : int
80+
New global ID of the simulation instance.
81+
"""
82+
83+
def get_global_id(self):
84+
"""
85+
Return the global ID of the simulation.
86+
"""
7287
```
7388

7489
A dummy code of a sample MicroSimulation class can be found in the [examples/python-dummy/micro_dummy.py](https://github.com/precice/micro-manager/blob/develop/examples/python-dummy/micro_dummy.py) directory.

docs/model-adaptivity.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,5 @@ The output is expected to be an integer and is interpreted in the following mann
169169
| 0 | No resolution change |
170170
| -1 | Increase model fidelity by one (go back one in list) |
171171
| 1 | Decrease model fidelity by one (go one ahead in list) |
172+
173+
If the switching function requests a change beyond the available resolution range, the request is ignored and does not trigger another model-adaptivity iteration.

examples/cpp-dummy/micro_cpp_dummy.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ int MicroSimulation::get_global_id() const
6565
return _sim_id;
6666
}
6767

68+
void MicroSimulation::set_global_id(int sim_id)
69+
{
70+
_sim_id = sim_id;
71+
}
72+
6873
PYBIND11_MODULE(micro_dummy, m) {
6974
// optional docstring
7075
m.doc() = "pybind11 micro dummy plugin";
@@ -75,6 +80,7 @@ PYBIND11_MODULE(micro_dummy, m) {
7580
.def("get_state", &MicroSimulation::get_state)
7681
.def("set_state", &MicroSimulation::set_state)
7782
.def("get_global_id", &MicroSimulation::get_global_id)
83+
.def("set_global_id", &MicroSimulation::set_global_id)
7884
// Pickling support does not work currently, as there is no way to pass the simulation ID to the new instance ms.
7985
.def(py::pickle( // https://pybind11.readthedocs.io/en/latest/advanced/classes.html#pickling-support
8086
[](const MicroSimulation &ms) { // __getstate__

examples/cpp-dummy/micro_cpp_dummy.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class MicroSimulation
2121
void set_state(py::list state);
2222
py::list get_state() const;
2323
int get_global_id() const;
24+
void set_global_id(int sim_id);
2425

2526
private:
2627
int _sim_id;

micro_manager/adaptivity/model_adaptivity.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def switch_models(
219219
sims[idx].destroy()
220220
sims[idx] = sim_new
221221

222-
return np.argwhere((current_res - target_res) != 0).tolist()
222+
return np.flatnonzero(current_res != target_res).tolist()
223223

224224
def update_states(
225225
self,
@@ -304,15 +304,10 @@ def check_convergence(
304304
size = len(sims)
305305
active_sims = self._create_active_mask(active_sim_ids, size)
306306
resolutions = self._gather_current_resolutions(sims, active_sims)
307-
next_switch = np.zeros_like(resolutions)
308-
for idx in range(active_sims.shape[0]):
309-
if active_sims[idx] != 1:
310-
continue
311-
prev_out = prev_output[idx] if prev_output is not None else None
312-
next_switch[idx] = self._switching_func(
313-
resolutions[idx], locations[idx], t, inputs[idx], prev_out
314-
)
315-
local_num_changes = np.sum(next_switch != 0)
307+
target_resolutions = self._gather_target_resolutions(
308+
resolutions, locations, t, inputs, prev_output, active_sims
309+
)
310+
local_num_changes = np.sum(target_resolutions != resolutions)
316311
global_num_changes = self._comm.allreduce(local_num_changes, op=MPI.SUM)
317312
self._converged = global_num_changes == 0
318313

micro_manager/micro_simulation.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,19 @@ def __getattr__(self, name):
555555
# Only add initialize override if the wrapped class actually has it,
556556
# so that requires_initialize() returns True for those classes.
557557
if has_initialize:
558-
class_body += """
559-
def initialize(self, *args, **kwargs):
560-
return self._wrapped.initialize(*args, **kwargs)
558+
argspec = inspect.getfullargspec(cls.initialize)
559+
# build args
560+
init_args = f"{', '.join(argspec.args)}"
561+
params = f"{', '.join(argspec.args[1::])}"
562+
if argspec.varargs is not None:
563+
init_args += f", *args"
564+
params += f", *args"
565+
if argspec.varkw is not None:
566+
init_args += f", **kwargs"
567+
params += f", **kwargs"
568+
class_body += f"""
569+
def initialize({init_args}):
570+
return self._wrapped.initialize({params})
561571
"""
562572

563573
# Only add output override if the wrapped class actually has it,

tests/integration/test_unit_cube/micro_dummy.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ def set_state(self, state):
5656

5757
def get_global_id(self):
5858
return self._sim_id
59+
60+
def set_global_id(self, sim_id):
61+
self._sim_id = sim_id
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
from unittest import TestCase
2+
from unittest.mock import MagicMock
3+
4+
import numpy as np
5+
from mpi4py import MPI
6+
7+
from micro_manager.adaptivity.model_adaptivity import ModelAdaptivity
8+
from micro_manager.micro_manager import MicroManagerCoupling
9+
10+
11+
class DummyModelClass:
12+
def __init__(self, name):
13+
self.name = name
14+
15+
16+
class DummySimulation:
17+
def __init__(self, name, global_id=0, late_init=False):
18+
self.name = name
19+
self._global_id = global_id
20+
self._state = {"state": name}
21+
self.attachments = {}
22+
self.destroyed = False
23+
self.late_init = late_init
24+
25+
def get_global_id(self):
26+
return self._global_id
27+
28+
def get_state(self):
29+
return self._state.copy()
30+
31+
def set_state(self, state):
32+
self._state = state.copy()
33+
34+
def destroy(self):
35+
self.destroyed = True
36+
37+
38+
class DummyModelManager:
39+
def __init__(self):
40+
self.created_instances = []
41+
42+
def get_instance(self, gid, target_class, *, late_init=False):
43+
self.created_instances.append(
44+
{
45+
"gid": gid,
46+
"target_class": target_class.name,
47+
"late_init": late_init,
48+
}
49+
)
50+
return DummySimulation(target_class.name, gid, late_init=late_init)
51+
52+
53+
class TestModelAdaptivity(TestCase):
54+
def _make_controller(self, switching_func):
55+
controller = ModelAdaptivity.__new__(ModelAdaptivity)
56+
controller._switching_func = switching_func
57+
controller._model_classes = [
58+
DummyModelClass("fine"),
59+
DummyModelClass("coarse"),
60+
]
61+
controller._model_manager = DummyModelManager()
62+
controller._comm = MPI.COMM_SELF
63+
controller._logger = MagicMock()
64+
controller._converged = False
65+
return controller
66+
67+
def test_check_convergence_ignores_invalid_switch_at_finest_resolution(self):
68+
"""
69+
Check that convergence is reached when the switching function requests a
70+
finer model while the simulation is already using the finest available
71+
resolution. Such an out-of-range request should be clamped to the
72+
current resolution and treated as no model change.
73+
"""
74+
controller = self._make_controller(lambda resolution, *_: -1)
75+
76+
controller.check_convergence(
77+
np.array([[0.0, 0.0, 0.0]]),
78+
1.0,
79+
[{}],
80+
None,
81+
[DummySimulation("fine")],
82+
)
83+
84+
self.assertTrue(controller._converged)
85+
86+
def test_check_convergence_ignores_invalid_switch_at_coarsest_resolution(self):
87+
"""
88+
Check that convergence is reached when the switching function requests a
89+
coarser model while the simulation is already using the coarsest
90+
available resolution. This guards against endless iterations caused by
91+
repeated out-of-range coarsening requests.
92+
"""
93+
controller = self._make_controller(lambda resolution, *_: 1)
94+
95+
controller.check_convergence(
96+
np.array([[0.0, 0.0, 0.0]]),
97+
1.0,
98+
[{}],
99+
None,
100+
[DummySimulation("coarse")],
101+
)
102+
103+
self.assertTrue(controller._converged)
104+
105+
def test_check_convergence_detects_valid_switch(self):
106+
"""
107+
Check that convergence is not reported when the switching function
108+
requests a valid change to another available model resolution. The
109+
adaptivity loop must continue in this case so the requested switch can
110+
be applied.
111+
"""
112+
controller = self._make_controller(lambda resolution, *_: 1)
113+
114+
controller.check_convergence(
115+
np.array([[0.0, 0.0, 0.0]]),
116+
1.0,
117+
[{}],
118+
None,
119+
[DummySimulation("fine")],
120+
)
121+
122+
self.assertFalse(controller._converged)
123+
124+
def test_manager_loop_switches_once_then_exits_on_invalid_boundary_request(self):
125+
"""
126+
Reproduce the regression scenario where a model is switched once and
127+
the switching function then keeps requesting another change beyond the
128+
available resolution range. The manager should solve with the new model,
129+
avoid reusing output from the previous model, and stop once the repeated
130+
boundary request is clamped to a no-op.
131+
"""
132+
133+
def switching_function(resolution, location, t, input, prev_output):
134+
if prev_output is None:
135+
return 0
136+
return 1
137+
138+
controller = self._make_controller(switching_function)
139+
manager = MicroManagerCoupling.__new__(MicroManagerCoupling)
140+
manager._model_adaptivity_controller = controller
141+
manager._is_adaptivity_on = False
142+
manager._mesh_vertex_coords = np.array([[0.0, 0.0, 0.0]])
143+
manager._global_ids_of_local_sims = [0]
144+
manager._t = 1.0
145+
manager._micro_sims = [DummySimulation("fine", global_id=0)]
146+
147+
solve_calls = []
148+
149+
def solve_variant(micro_sims_input, dt, computed_outputs):
150+
solve_calls.append(
151+
{
152+
"sim_name": manager._micro_sims[0].name,
153+
"computed_outputs": computed_outputs.copy(),
154+
}
155+
)
156+
return [{"result": len(solve_calls)}]
157+
158+
result = MicroManagerCoupling._solve_micro_simulations_with_model_adaptivity(
159+
manager,
160+
[{"input": 1.0}],
161+
0.1,
162+
solve_variant,
163+
)
164+
165+
self.assertEqual(len(solve_calls), 2)
166+
self.assertEqual(solve_calls[0]["sim_name"], "fine")
167+
self.assertEqual(solve_calls[0]["computed_outputs"], {})
168+
169+
self.assertEqual(solve_calls[1]["sim_name"], "coarse")
170+
self.assertEqual(
171+
solve_calls[1]["computed_outputs"],
172+
{},
173+
"Output from the previous resolution must not be reused after a model switch.",
174+
)
175+
176+
self.assertEqual(manager._micro_sims[0].name, "coarse")
177+
self.assertEqual(result, [{"result": 2}])
178+
self.assertTrue(controller._converged)
179+
180+
def test_manager_loop_exits_on_invalid_switch_request(self):
181+
"""
182+
Check the manager loop for the simpler boundary case where the
183+
simulation starts at the coarsest model and the switching function keeps
184+
requesting an even coarser model. The loop should perform one solve,
185+
recognize that no valid model change remains, and return normally.
186+
"""
187+
controller = self._make_controller(lambda resolution, *_: 1)
188+
manager = MicroManagerCoupling.__new__(MicroManagerCoupling)
189+
manager._model_adaptivity_controller = controller
190+
manager._is_adaptivity_on = False
191+
manager._mesh_vertex_coords = np.array([[0.0, 0.0, 0.0]])
192+
manager._global_ids_of_local_sims = [0]
193+
manager._t = 1.0
194+
manager._micro_sims = [DummySimulation("coarse")]
195+
196+
solve_calls = []
197+
198+
def solve_variant(micro_sims_input, dt, computed_outputs):
199+
solve_calls.append(
200+
{
201+
"micro_sims_input": micro_sims_input,
202+
"dt": dt,
203+
"computed_outputs": computed_outputs,
204+
}
205+
)
206+
return [{"result": 1.0}]
207+
208+
result = MicroManagerCoupling._solve_micro_simulations_with_model_adaptivity(
209+
manager,
210+
[{"input": 1.0}],
211+
0.1,
212+
solve_variant,
213+
)
214+
215+
self.assertEqual(len(solve_calls), 1)
216+
self.assertEqual(solve_calls[0]["computed_outputs"], {})
217+
self.assertEqual(result, [{"result": 1.0}])

0 commit comments

Comments
 (0)