diff --git a/docs/tutorial/soil_tutorial.ipynb b/docs/tutorial/soil_tutorial.ipynb index a872bc3..5cb3349 100644 --- a/docs/tutorial/soil_tutorial.ipynb +++ b/docs/tutorial/soil_tutorial.ipynb @@ -69,7 +69,7 @@ }, { "cell_type": "code", - "execution_count": 64, + "execution_count": 1, "metadata": { "ExecuteTime": { "end_time": "2017-11-03T10:58:13.451481Z", @@ -318,10 +318,11 @@ "\n", "By default, every agent will be called in every simulation step, and the time elapsed between two steps is controlled by the `interval` attribute in the environment.\n", "\n", - "But agents may signal the scheduler how long to wait before calling them again by returning (or `yield`ing) a value other than `None`.\n", + "But agents may signal the scheduler how long to wait before calling them again by returning a value other than `None` or using `await` in asynchronous functions.\n", "This is especially useful when an agent is going to be dormant for a long time.\n", "There are two convenience methods to calculate the value to return: `Agent.delay`, which takes a time delay; and `Agent.at`, which takes an absolute time at which the agent should be awaken.\n", - "A return (or `yield`) value of `None` will default to a wait of 1 unit of time.\n", + "A return value of `None` will default to a wait of 1 unit of time.\n", + "Both `Agent.at` and `Agent.delay` can be awaited in async functions.\n", "\n", "When an `FSM` agent returns, it may signal two things: how long to wait, and a state to transition to.\n", "This can be done by using the `delay` and `at` methods of each state." @@ -360,7 +361,7 @@ }, { "cell_type": "code", - "execution_count": 65, + "execution_count": 2, "metadata": { "ExecuteTime": { "end_time": "2017-11-03T10:58:17.653736Z", @@ -409,14 +410,14 @@ }, { "cell_type": "code", - "execution_count": 66, + "execution_count": 3, "metadata": { "hideCode": false, "hidePrompt": false }, "outputs": [], "source": [ - "class NewsEnvSimple(NetworkEnvironment):\n", + "class NewsEnvSimple(Environment):\n", " \n", " # Here we set the default parameters for our model\n", " # We will be able to override them on a per-simulation basis\n", @@ -444,7 +445,7 @@ }, { "cell_type": "code", - "execution_count": 67, + "execution_count": 4, "metadata": { "hideCode": false, "hidePrompt": false @@ -453,7 +454,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "913bbb91650841e6afee444b2c1f4636", + "model_id": "6f87549b81a84699900e5398ba5df413", "version_major": 2, "version_minor": 0 }, @@ -467,7 +468,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3febc88a480b4e80aa35dfff331f54f6", + "model_id": "27ce4ef734f6465f8a8cddd09a82aa1d", "version_major": 2, "version_minor": 0 }, @@ -562,7 +563,7 @@ "14.0 5 1 0.0" ] }, - "execution_count": 67, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -615,7 +616,7 @@ }, { "cell_type": "code", - "execution_count": 68, + "execution_count": 5, "metadata": { "ExecuteTime": { "end_time": "2017-11-03T10:58:16.051690Z", @@ -662,7 +663,7 @@ }, { "cell_type": "code", - "execution_count": 69, + "execution_count": 6, "metadata": { "hideCode": false, "hidePrompt": false @@ -674,7 +675,7 @@ "['dead', 'neutral', 'infected']" ] }, - "execution_count": 69, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -709,7 +710,7 @@ }, { "cell_type": "code", - "execution_count": 70, + "execution_count": 7, "metadata": { "cell_style": "split", "hideCode": false, @@ -718,7 +719,7 @@ "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -744,7 +745,7 @@ }, { "cell_type": "code", - "execution_count": 71, + "execution_count": 8, "metadata": { "hideCode": false, "hidePrompt": false @@ -770,7 +771,7 @@ }, { "cell_type": "code", - "execution_count": 72, + "execution_count": 9, "metadata": { "hideCode": false, "hidePrompt": false @@ -779,7 +780,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "450bd9491a774e34bbb8d1454744e83f", + "model_id": "a0ec3c65dc1f43cbac69d651ba4a1d52", "version_major": 2, "version_minor": 0 }, @@ -793,7 +794,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b9f8255c3d63430191894f44575e49a1", + "model_id": "c8c077d73a5c4c868cb30c43fdc5120c", "version_major": 2, "version_minor": 0 }, @@ -1016,7 +1017,7 @@ "20.0 20 6 0.0 0.077484" ] }, - "execution_count": 72, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -1040,7 +1041,7 @@ }, { "cell_type": "code", - "execution_count": 73, + "execution_count": 10, "metadata": { "hideCode": false, "hidePrompt": false @@ -1092,7 +1093,7 @@ }, { "cell_type": "code", - "execution_count": 74, + "execution_count": 11, "metadata": { "hideCode": false, "hidePrompt": false @@ -1136,7 +1137,7 @@ }, { "cell_type": "code", - "execution_count": 75, + "execution_count": 12, "metadata": { "hideCode": false, "hidePrompt": false @@ -1146,13 +1147,13 @@ "name": "stderr", "output_type": "stream", "text": [ - "[INFO ][12:09:51] Output directory: /mnt/data/home/j/git/lab.gsi/soil/soil/docs/tutorial/soil_output\n" + "[INFO ][17:13:25] Output directory: /mnt/data/home/j/git/lab.gsi/soil/soil/docs/tutorial/soil_output\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "7218eee842324b00b113e087ae593226", + "model_id": "a464d1f51eb44e02bf1a686cd7fa6c6e", "version_major": 2, "version_minor": 0 }, @@ -1175,7 +1176,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "dacc83fb5de94c38b448eda1cf08ae66", + "model_id": "6fd9e62306a843a9a9d7e62299d0ed5d", "version_major": 2, "version_minor": 0 }, @@ -1198,7 +1199,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "14ce9a11c3f4415a9408a4fea5152ae5", + "model_id": "5b78dc37d1b04ea59b8047f92be52d08", "version_major": 2, "version_minor": 0 }, @@ -1221,7 +1222,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "bc095b24b3a7477d9fb5320e98fc342d", + "model_id": "34aa57dae29f4e39a424fdb4a81eb082", "version_major": 2, "version_minor": 0 }, @@ -1244,7 +1245,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c9802329d9ce43d996fd6939e6605d78", + "model_id": "861293f830944ef2816dab34753eb7ce", "version_major": 2, "version_minor": 0 }, @@ -1267,7 +1268,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8398c6414f404f60a77439d1c3dd8c35", + "model_id": "fdeb39e180ee443e8d3ee4cf38622691", "version_major": 2, "version_minor": 0 }, @@ -1290,7 +1291,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c4291f99929144a4aff08f921dee4598", + "model_id": "f18cfb2d7148469b8274f0ba4678ac21", "version_major": 2, "version_minor": 0 }, @@ -1313,7 +1314,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "312773d015ff4b47aec40ea03a67c64d", + "model_id": "8d026960ec51460095d9453c6aaf42e4", "version_major": 2, "version_minor": 0 }, @@ -1336,7 +1337,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d505285f26b14435be77ba772f75bb1a", + "model_id": "aad1914202cd4dec99658d703a20e040", "version_major": 2, "version_minor": 0 }, @@ -1359,7 +1360,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "11956a545d4145e7ba5a13a67b1c4e64", + "model_id": "dc32f62b2d65413d9582f8a0da23c1fe", "version_major": 2, "version_minor": 0 }, @@ -1382,7 +1383,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "e52934e005fc45d2b94c18737447bc03", + "model_id": "78d7c50814394e47bc0b06c065e785ac", "version_major": 2, "version_minor": 0 }, @@ -1407,7 +1408,7 @@ }, { "cell_type": "code", - "execution_count": 76, + "execution_count": 13, "metadata": { "hideCode": false, "hidePrompt": false @@ -1437,7 +1438,7 @@ }, { "cell_type": "code", - "execution_count": 77, + "execution_count": 14, "metadata": { "ExecuteTime": { "end_time": "2017-11-01T14:05:56.404540Z", @@ -1454,11 +1455,10 @@ "text": [ "\u001b[01;34msoil_output\u001b[00m\n", "└── \u001b[01;34mnewspread\u001b[00m\n", - " ├── newspread_1683108591.8199563.dumped.yml\n", " └── newspread.sqlite\n", "\n", - "1 directory, 2 files\n", - "21M\tsoil_output/newspread\n" + "1 directory, 1 file\n", + "4.5M\tsoil_output/newspread\n" ] } ], @@ -1537,7 +1537,7 @@ }, { "cell_type": "code", - "execution_count": 78, + "execution_count": 15, "metadata": { "ExecuteTime": { "end_time": "2017-10-19T15:57:44.101253Z", @@ -1564,7 +1564,7 @@ }, { "cell_type": "code", - "execution_count": 79, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -1593,7 +1593,7 @@ }, { "cell_type": "code", - "execution_count": 80, + "execution_count": 17, "metadata": { "hideCode": false, "hidePrompt": false @@ -1648,7 +1648,7 @@ }, { "cell_type": "code", - "execution_count": 81, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -1673,15 +1673,17 @@ " \n", " \n", " \n", - " key\n", - " generator\n", + " \n", + " index\n", " n\n", + " generator\n", " prob_neighbor_spread\n", " \n", " \n", - " iteration_id\n", - " params_id\n", " simulation_id\n", + " params_id\n", + " iteration_id\n", + " \n", " \n", " \n", " \n", @@ -1689,64 +1691,73 @@ " \n", " \n", " \n", - " 0\n", - " 39063f8\n", - " newspread_1683108591.8199563\n", + " newspread_1683213205.589173\n", + " ff1d24a\n", + " 0\n", + " 0\n", + " 100\n", " erdos_renyi_graph\n", - " 100\n", - " 1.0\n", + " 0\n", " \n", " \n", - " 8f26adb\n", - " newspread_1683108591.8199563\n", - " barabasi_albert_graph\n", + " 1\n", + " 0\n", " 100\n", - " 0.5\n", - " \n", - " \n", - " 92fdcb9\n", - " newspread_1683108591.8199563\n", " erdos_renyi_graph\n", - " 100\n", - " 0.25\n", + " 0\n", " \n", " \n", - " cb3dbca\n", - " newspread_1683108591.8199563\n", + " 2\n", + " 0\n", + " 100\n", " erdos_renyi_graph\n", - " 100\n", - " 0.5\n", + " 0\n", " \n", " \n", - " d1fe9c1\n", - " newspread_1683108591.8199563\n", - " barabasi_albert_graph\n", + " 3\n", + " 0\n", " 100\n", - " 1.0\n", + " erdos_renyi_graph\n", + " 0\n", + " \n", + " \n", + " 4\n", + " 0\n", + " 100\n", + " erdos_renyi_graph\n", + " 0\n", " \n", " \n", "\n", "" ], "text/plain": [ - "key generator \\\n", - "iteration_id params_id simulation_id \n", - "0 39063f8 newspread_1683108591.8199563 erdos_renyi_graph \n", - " 8f26adb newspread_1683108591.8199563 barabasi_albert_graph \n", - " 92fdcb9 newspread_1683108591.8199563 erdos_renyi_graph \n", - " cb3dbca newspread_1683108591.8199563 erdos_renyi_graph \n", - " d1fe9c1 newspread_1683108591.8199563 barabasi_albert_graph \n", + " index n \\\n", + "simulation_id params_id iteration_id \n", + "newspread_1683213205.589173 ff1d24a 0 0 100 \n", + " 1 0 100 \n", + " 2 0 100 \n", + " 3 0 100 \n", + " 4 0 100 \n", "\n", - "key n prob_neighbor_spread \n", - "iteration_id params_id simulation_id \n", - "0 39063f8 newspread_1683108591.8199563 100 1.0 \n", - " 8f26adb newspread_1683108591.8199563 100 0.5 \n", - " 92fdcb9 newspread_1683108591.8199563 100 0.25 \n", - " cb3dbca newspread_1683108591.8199563 100 0.5 \n", - " d1fe9c1 newspread_1683108591.8199563 100 1.0 " + " generator \\\n", + "simulation_id params_id iteration_id \n", + "newspread_1683213205.589173 ff1d24a 0 erdos_renyi_graph \n", + " 1 erdos_renyi_graph \n", + " 2 erdos_renyi_graph \n", + " 3 erdos_renyi_graph \n", + " 4 erdos_renyi_graph \n", + "\n", + " prob_neighbor_spread \n", + "simulation_id params_id iteration_id \n", + "newspread_1683213205.589173 ff1d24a 0 0 \n", + " 1 0 \n", + " 2 0 \n", + " 3 0 \n", + " 4 0 " ] }, - "execution_count": 81, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -1767,7 +1778,7 @@ }, { "cell_type": "code", - "execution_count": 82, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -1840,7 +1851,7 @@ " \n", " \n", " \n", - " newspread_1683108591.8199563\n", + " newspread_1683213205.589173\n", " 0\n", " 2\n", " None\n", @@ -1853,7 +1864,7 @@ " True\n", " ...\n", " 1\n", - " [\"<class 'soil.exporters.default'>\"]\n", + " [\"<class 'soil.exporters.SQLite'>\"]\n", " {}\n", " {}\n", " {}\n", @@ -1869,38 +1880,38 @@ "" ], "text/plain": [ - " index version source_file name \\\n", - "simulation_id \n", - "newspread_1683108591.8199563 0 2 None newspread \n", - "\n", - " description group backup overwrite dry_run dump \\\n", + " index version source_file name description \\\n", "simulation_id \n", - "newspread_1683108591.8199563 None False True False True \n", + "newspread_1683213205.589173 0 2 None newspread \n", "\n", - " ... num_processes \\\n", - "simulation_id ... \n", - "newspread_1683108591.8199563 ... 1 \n", + " group backup overwrite dry_run dump ... \\\n", + "simulation_id ... \n", + "newspread_1683213205.589173 None False True False True ... \n", "\n", - " exporters \\\n", + " num_processes \\\n", + "simulation_id \n", + "newspread_1683213205.589173 1 \n", + "\n", + " exporters \\\n", + "simulation_id \n", + "newspread_1683213205.589173 [\"\"] \n", + "\n", + " model_reporters agent_reporters tables \\\n", "simulation_id \n", - "newspread_1683108591.8199563 [\"\"] \n", + "newspread_1683213205.589173 {} {} {} \n", "\n", - " model_reporters agent_reporters tables \\\n", - "simulation_id \n", - "newspread_1683108591.8199563 {} {} {} \n", + " outdir \\\n", + "simulation_id \n", + "newspread_1683213205.589173 /mnt/data/home/j/git/lab.gsi/soil/soil/docs/tu... \n", "\n", - " outdir \\\n", - "simulation_id \n", - "newspread_1683108591.8199563 /mnt/data/home/j/git/lab.gsi/soil/soil/docs/tu... \n", - "\n", - " exporter_params level skip_test debug \n", - "simulation_id \n", - "newspread_1683108591.8199563 {} 20 False False \n", + " exporter_params level skip_test debug \n", + "simulation_id \n", + "newspread_1683213205.589173 {} 20 False False \n", "\n", "[1 rows x 28 columns]" ] }, - "execution_count": 82, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -1921,7 +1932,7 @@ }, { "cell_type": "code", - "execution_count": 83, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -1947,14 +1958,12 @@ " \n", " \n", " \n", - " \n", " agent_count\n", " time\n", " prob_tv_spread\n", " prob_neighbor_spread\n", " \n", " \n", - " simulation_id\n", " params_id\n", " iteration_id\n", " step\n", @@ -1966,7 +1975,6 @@ " \n", " \n", " \n", - " newspread_1683108591.8199563\n", " ff1d24a\n", " 0\n", " 0\n", @@ -2008,32 +2016,24 @@ "" ], "text/plain": [ - " agent_count time \\\n", - "simulation_id params_id iteration_id step \n", - "newspread_1683108591.8199563 ff1d24a 0 0 101 0.0 \n", - " 1 101 1.0 \n", - " 2 101 2.0 \n", - " 3 101 3.0 \n", - " 4 101 4.0 \n", + " agent_count time prob_tv_spread \\\n", + "params_id iteration_id step \n", + "ff1d24a 0 0 101 0.0 0.0 \n", + " 1 101 1.0 0.0 \n", + " 2 101 2.0 0.0 \n", + " 3 101 3.0 0.0 \n", + " 4 101 4.0 0.0 \n", "\n", - " prob_tv_spread \\\n", - "simulation_id params_id iteration_id step \n", - "newspread_1683108591.8199563 ff1d24a 0 0 0.0 \n", - " 1 0.0 \n", - " 2 0.0 \n", - " 3 0.0 \n", - " 4 0.0 \n", - "\n", - " prob_neighbor_spread \n", - "simulation_id params_id iteration_id step \n", - "newspread_1683108591.8199563 ff1d24a 0 0 0.0 \n", - " 1 0.0 \n", - " 2 0.0 \n", - " 3 0.0 \n", - " 4 0.0 " + " prob_neighbor_spread \n", + "params_id iteration_id step \n", + "ff1d24a 0 0 0.0 \n", + " 1 0.0 \n", + " 2 0.0 \n", + " 3 0.0 \n", + " 4 0.0 " ] }, - "execution_count": 83, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -2056,7 +2056,7 @@ }, { "cell_type": "code", - "execution_count": 84, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -2083,11 +2083,9 @@ " \n", " \n", " \n", - " \n", " state_id\n", " \n", " \n", - " simulation_id\n", " params_id\n", " iteration_id\n", " step\n", @@ -2097,7 +2095,6 @@ " \n", " \n", " \n", - " newspread_1683108591.8199563\n", " ff1d24a\n", " 0\n", " 0\n", @@ -2125,16 +2122,16 @@ "" ], "text/plain": [ - " state_id\n", - "simulation_id params_id iteration_id step agent_id \n", - "newspread_1683108591.8199563 ff1d24a 0 0 0 None\n", - " 1 neutral\n", - " 2 neutral\n", - " 3 neutral\n", - " 4 neutral" + " state_id\n", + "params_id iteration_id step agent_id \n", + "ff1d24a 0 0 0 None\n", + " 1 neutral\n", + " 2 neutral\n", + " 3 neutral\n", + " 4 neutral" ] }, - "execution_count": 84, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } diff --git a/examples/cars/cars_sim.py b/examples/cars/cars_sim.py index 3b38a16..02a5ee9 100644 --- a/examples/cars/cars_sim.py +++ b/examples/cars/cars_sim.py @@ -43,7 +43,7 @@ class Journey: driver: Optional[Driver] = None -class City(EventedEnvironment): +class City(Environment): """ An environment with a grid where drivers and passengers will be placed. @@ -85,11 +85,12 @@ class Driver(Evented, FSM): journey = None earnings = 0 - def on_receive(self, msg, sender): - """This is not a state. It will run (and block) every time process_messages is invoked""" - if self.journey is None and isinstance(msg, Journey) and msg.driver is None: - msg.driver = self - self.journey = msg + # TODO: remove + # def on_receive(self, msg, sender): + # """This is not a state. It will run (and block) every time process_messages is invoked""" + # if self.journey is None and isinstance(msg, Journey) and msg.driver is None: + # msg.driver = self + # self.journey = msg def check_passengers(self): """If there are no more passengers, stop forever""" @@ -104,7 +105,7 @@ class Driver(Evented, FSM): if not self.check_passengers(): return self.die("No passengers left") self.journey = None - while self.journey is None: # No potential journeys detected (see on_receive) + while self.journey is None: # No potential journeys detected if target is None or not self.move_towards(target): target = self.random.choice( self.model.grid.get_neighborhood(self.pos, moore=False) @@ -113,7 +114,7 @@ class Driver(Evented, FSM): if not self.check_passengers(): return self.die("No passengers left") # This will call on_receive behind the scenes, and the agent's status will be updated - self.process_messages() + await self.delay(30) # Wait at least 30 seconds before checking again try: @@ -167,12 +168,13 @@ class Driver(Evented, FSM): class Passenger(Evented, FSM): pos = None - def on_receive(self, msg, sender): - """This is not a state. It will be run synchronously every time `process_messages` is run""" + # TODO: Remove + # def on_receive(self, msg, sender): + # """This is not a state. It will be run synchronously every time `process_messages` is run""" - if isinstance(msg, Journey): - self.journey = msg - return msg + # if isinstance(msg, Journey): + # self.journey = msg + # return msg @default_state @state @@ -192,17 +194,34 @@ class Passenger(Evented, FSM): timeout = 60 expiration = self.now + timeout self.info(f"Asking for journey at: { self.pos }") - self.model.broadcast(journey, ttl=timeout, sender=self, agent_class=Driver) + self.broadcast(journey, ttl=timeout, agent_class=Driver) while not self.journey: self.debug(f"Waiting for responses at: { self.pos }") try: - # This will call process_messages behind the scenes, and the agent's status will be updated - # If you want to avoid that, you can call it with: check=False - await self.received(expiration=expiration, delay=10) + offers = await self.received(expiration=expiration, delay=10) + accepted = None + for event in offers: + offer = event.payload + if isinstance(offer, Journey): + self.journey = offer + assert isinstance(event.sender, Driver) + try: + answer = await event.sender.ask(True, sender=self, timeout=60, delay=5) + if answer: + accepted = offer + self.journey = offer + break + except events.TimedOut: + pass + if accepted: + for event in offers: + if event.payload != accepted: + event.sender.tell(False, timeout=60, delay=5) + except events.TimedOut: self.info(f"Still no response. Waiting at: { self.pos }") - self.model.broadcast( - journey, ttl=timeout, sender=self, agent_class=Driver + self.broadcast( + journey, ttl=timeout, agent_class=Driver ) expiration = self.now + timeout self.info(f"Got a response! Waiting for driver") diff --git a/examples/custom_timeouts/custom_timeouts.py b/examples/custom_timeouts/custom_timeouts.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/custom_timeouts/custom_timeouts_sim.py b/examples/custom_timeouts_sim.py similarity index 100% rename from examples/custom_timeouts/custom_timeouts_sim.py rename to examples/custom_timeouts_sim.py diff --git a/examples/custom_generator/generator_sim.py b/examples/generator_sim.py similarity index 100% rename from examples/custom_generator/generator_sim.py rename to examples/generator_sim.py diff --git a/examples/newsspread/newsspread_sim.py b/examples/newsspread/newsspread_sim.py index 4fa51f1..5db0295 100644 --- a/examples/newsspread/newsspread_sim.py +++ b/examples/newsspread/newsspread_sim.py @@ -1,4 +1,4 @@ -from soil.agents import FSM, NetworkAgent, state, default_state, prob +from soil.agents import FSM, NetworkAgent, state, default_state from soil.parameters import * import logging diff --git a/examples/rabbits/README.md b/examples/rabbits/README.md index dfee8ef..04ff36a 100644 --- a/examples/rabbits/README.md +++ b/examples/rabbits/README.md @@ -1,7 +1,7 @@ There are two similar implementations of this simulation. - `basic`. Using simple primites -- `improved`. Using more advanced features such as the `time` module to avoid unnecessary computations (i.e., skip steps), and generator functions. +- `improved`. Using more advanced features such as the delays to avoid unnecessary computations (i.e., skip steps). The examples can be run directly in the terminal, and they accept command like arguments. For example, to enable the CSV exporter and the Summary exporter, while setting `max_time` to `100` and `seed` to `CustomSeed`: diff --git a/examples/rabbits/rabbit_improved_sim.py b/examples/rabbits/rabbit_improved_sim.py index f4bc8df..c6eb015 100644 --- a/examples/rabbits/rabbit_improved_sim.py +++ b/examples/rabbits/rabbit_improved_sim.py @@ -1,22 +1,36 @@ -from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation -from enum import Enum -from collections import Counter -import logging +from soil import Evented, FSM, state, default_state, BaseAgent, NetworkAgent, Environment, parameters, report, TimedOut import math -from rabbits_basic_sim import RabbitEnv +from soilent import Scheduler -class RabbitsImprovedEnv(RabbitEnv): +class RabbitsImprovedEnv(Environment): + prob_death: parameters.probability = 1e-3 + schedule_class = Scheduler + def init(self): - """Initialize the environment with the new versions of the agents""" a1 = self.add_node(Male) a2 = self.add_node(Female) a1.add_edge(a2) self.add_agent(RandomAccident) + @report + @property + def num_rabbits(self): + return self.count_agents(agent_class=Rabbit) -class Rabbit(FSM, NetworkAgent): + @report + @property + def num_males(self): + return self.count_agents(agent_class=Male) + + @report + @property + def num_females(self): + return self.count_agents(agent_class=Female) + + +class Rabbit(Evented, FSM, NetworkAgent): sexual_maturity = 30 life_expectancy = 300 @@ -31,42 +45,40 @@ class Rabbit(FSM, NetworkAgent): @default_state @state def newborn(self): - self.info("I am a newborn.") + self.debug("I am a newborn.") self.birth = self.now self.offspring = 0 - return self.youngling.delay(self.sexual_maturity - self.age) + return self.youngling @state - def youngling(self): - if self.age >= self.sexual_maturity: - self.info(f"I am fertile! My age is {self.age}") - return self.fertile + async def youngling(self): + self.debug("I am a youngling.") + await self.delay(self.sexual_maturity - self.age) + assert self.age >= self.sexual_maturity + self.debug(f"I am fertile! My age is {self.age}") + return self.fertile @state def fertile(self): raise Exception("Each subclass should define its fertile state") - @state - def dead(self): - self.die() - class Male(Rabbit): max_females = 5 - mating_prob = 0.001 + mating_prob = 0.005 @state def fertile(self): if self.age > self.life_expectancy: - return self.dead + return self.die() # Males try to mate for f in self.model.agents( agent_class=Female, state_id=Female.fertile.id, limit=self.max_females ): - self.debug("FOUND A FEMALE: ", repr(f), self.mating_prob) + self.debug(f"FOUND A FEMALE: {repr(f)}. Mating with prob {self.mating_prob}") if self.prob(self["mating_prob"]): - f.impregnate(self) + f.tell(self.unique_id, sender=self, timeout=1) break # Do not try to impregnate other females @@ -75,78 +87,91 @@ class Female(Rabbit): conception = None @state - def fertile(self): + async def fertile(self): # Just wait for a Male - if self.age > self.life_expectancy: - return self.dead - if self.conception is not None: - return self.pregnant - - @property - def pregnancy(self): - if self.conception is None: - return None - return self.now - self.conception - - def impregnate(self, male): - self.info(f"impregnated by {repr(male)}") - self.mate = male - self.conception = self.now - self.number_of_babies = int(8 + 4 * self.random.random()) + try: + timeout = self.life_expectancy - self.age + while timeout > 0: + mates = await self.received(timeout=timeout) + # assert self.age <= self.life_expectancy + for mate in mates: + try: + male = self.model.agents[mate.payload] + except ValueError: + continue + self.debug(f"impregnated by {repr(male)}") + self.mate = male + self.number_of_babies = int(8 + 4 * self.random.random()) + self.conception = self.now + return self.pregnant + except TimedOut: + pass + return self.die() @state - def pregnant(self): + async def pregnant(self): self.debug("I am pregnant") + # assert self.mate is not None + + when = min(self.gestation, self.life_expectancy - self.age) + if when < 0: + return self.die() + await self.delay(when) if self.age > self.life_expectancy: - self.info("Dying before giving birth") + self.debug("Dying before giving birth") return self.die() - if self.pregnancy >= self.gestation: - self.info("Having {} babies".format(self.number_of_babies)) - for i in range(self.number_of_babies): - state = {} - agent_class = self.random.choice([Male, Female]) - child = self.model.add_node(agent_class=agent_class, **state) - child.add_edge(self) - if self.mate: - child.add_edge(self.mate) - self.mate.offspring += 1 - else: - self.debug("The father has passed away") + # assert self.now - self.conception >= self.gestation + if not self.alive: + return self.die() - self.offspring += 1 - self.mate = None - return self.fertile + self.debug("Having {} babies".format(self.number_of_babies)) + for i in range(self.number_of_babies): + state = {} + agent_class = self.random.choice([Male, Female]) + child = self.model.add_node(agent_class=agent_class, **state) + child.add_edge(self) + try: + child.add_edge(self.mate) + self.model.agents[self.mate].offspring += 1 + except ValueError: + self.debug("The father has passed away") + + self.offspring += 1 + self.mate = None + self.conception = None + return self.fertile def die(self): - if self.pregnancy is not None: - self.info("A mother has died carrying a baby!!") + if self.conception is not None: + self.debug("A mother has died carrying a baby!!") return super().die() class RandomAccident(BaseAgent): + # Default value, but the value from the environment takes precedence + prob_death = 1e-3 + def step(self): - rabbits_alive = self.model.G.number_of_nodes() - if not rabbits_alive: - return self.die() + alive = self.get_agents(agent_class=Rabbit, alive=True) - prob_death = self.model.get("prob_death", 1e-100) * math.floor( - math.log10(max(1, rabbits_alive)) - ) + if not alive: + return self.die("No more rabbits to kill") + + num_alive = len(alive) + prob_death = min(1, self.prob_death * num_alive/10) self.debug("Killing some rabbits with prob={}!".format(prob_death)) - for i in self.iter_agents(agent_class=Rabbit): + + for i in alive: if i.state_id == i.dead.id: continue if self.prob(prob_death): - self.info("I killed a rabbit: {}".format(i.id)) - rabbits_alive -= 1 + self.debug("I killed a rabbit: {}".format(i.unique_id)) + num_alive -= 1 i.die() - self.debug("Rabbits alive: {}".format(rabbits_alive)) + self.debug("Rabbits alive: {}".format(num_alive)) -sim = Simulation(model=RabbitsImprovedEnv, max_time=100, seed="MySeed", iterations=1) - -if __name__ == "__main__": - sim.run() +RabbitsImprovedEnv.run(max_time=1000, seed="MySeed", iterations=1) \ No newline at end of file diff --git a/examples/rabbits/rabbits_basic_sim.py b/examples/rabbits/rabbits_basic_sim.py index 553eb43..d70a958 100644 --- a/examples/rabbits/rabbits_basic_sim.py +++ b/examples/rabbits/rabbits_basic_sim.py @@ -1,11 +1,9 @@ -from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation, report, parameters as params -from collections import Counter -import logging +from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, report, parameters as params import math class RabbitEnv(Environment): - prob_death: params.probability = 1e-100 + prob_death: params.probability = 1e-3 def init(self): a1 = self.add_node(Male) @@ -37,7 +35,7 @@ class Rabbit(NetworkAgent, FSM): @default_state @state def newborn(self): - self.info("I am a newborn.") + self.debug("I am a newborn.") self.age = 0 self.offspring = 0 return self.youngling @@ -46,7 +44,7 @@ class Rabbit(NetworkAgent, FSM): def youngling(self): self.age += 1 if self.age >= self.sexual_maturity: - self.info(f"I am fertile! My age is {self.age}") + self.debug(f"I am fertile! My age is {self.age}") return self.fertile @state @@ -60,7 +58,7 @@ class Rabbit(NetworkAgent, FSM): class Male(Rabbit): max_females = 5 - mating_prob = 0.001 + mating_prob = 0.005 @state def fertile(self): @@ -70,9 +68,8 @@ class Male(Rabbit): return self.dead # Males try to mate - for f in self.model.agents( - agent_class=Female, state_id=Female.fertile.id, limit=self.max_females - ): + for f in self.model.agents.filter( + agent_class=Female, state_id=Female.fertile.id).limit(self.max_females): self.debug("FOUND A FEMALE: ", repr(f), self.mating_prob) if self.prob(self["mating_prob"]): f.impregnate(self) @@ -93,14 +90,14 @@ class Female(Rabbit): return self.pregnant def impregnate(self, male): - self.info(f"impregnated by {repr(male)}") + self.debug(f"impregnated by {repr(male)}") self.mate = male self.pregnancy = 0 self.number_of_babies = int(8 + 4 * self.random.random()) @state def pregnant(self): - self.info("I am pregnant") + self.debug("I am pregnant") self.age += 1 if self.age >= self.life_expectancy: @@ -110,7 +107,7 @@ class Female(Rabbit): self.pregnancy += 1 return - self.info("Having {} babies".format(self.number_of_babies)) + self.debug("Having {} babies".format(self.number_of_babies)) for i in range(self.number_of_babies): state = {} agent_class = self.random.choice([Male, Female]) @@ -129,33 +126,30 @@ class Female(Rabbit): def die(self): if "pregnancy" in self and self["pregnancy"] > -1: - self.info("A mother has died carrying a baby!!") + self.debug("A mother has died carrying a baby!!") return super().die() class RandomAccident(BaseAgent): + prob_death = None def step(self): - rabbits_alive = self.model.G.number_of_nodes() + alive = self.get_agents(agent_class=Rabbit, alive=True) - if not rabbits_alive: - return self.die() + if not alive: + return self.die("No more rabbits to kill") - prob_death = self.model.prob_death * math.floor( - math.log10(max(1, rabbits_alive)) - ) + num_alive = len(alive) + prob_death = min(1, self.prob_death * num_alive/10) self.debug("Killing some rabbits with prob={}!".format(prob_death)) + for i in self.get_agents(agent_class=Rabbit): if i.state_id == i.dead.id: continue if self.prob(prob_death): - self.info("I killed a rabbit: {}".format(i.id)) - rabbits_alive -= 1 + self.debug("I killed a rabbit: {}".format(i.unique_id)) + num_alive -= 1 i.die() - self.debug("Rabbits alive: {}".format(rabbits_alive)) + self.debug("Rabbits alive: {}".format(num_alive)) - -sim = Simulation(model=RabbitEnv, max_time=100, seed="MySeed", iterations=1) - -if __name__ == "__main__": - sim.run() \ No newline at end of file +RabbitEnv.run(max_time=1000, seed="MySeed", iterations=1) \ No newline at end of file diff --git a/examples/terrorism/TerroristNetworkModel_sim.py b/examples/terrorism/TerroristNetworkModel_sim.py index 52e774f..8668084 100644 --- a/examples/terrorism/TerroristNetworkModel_sim.py +++ b/examples/terrorism/TerroristNetworkModel_sim.py @@ -1,5 +1,5 @@ import networkx as nx -from soil.agents import NetworkAgent, FSM, custom, state, default_state +from soil.agents import FSM, state, default_state from soil.agents.geo import Geo from soil import Environment, Simulation from soil.parameters import * diff --git a/soil/VERSION b/soil/VERSION index 1bb1990..9de6f86 100644 --- a/soil/VERSION +++ b/soil/VERSION @@ -1 +1 @@ -1.0.0rc3 +1.0.0rc6 diff --git a/soil/__init__.py b/soil/__init__.py index a4ce59d..537cac5 100644 --- a/soil/__init__.py +++ b/soil/__init__.py @@ -19,7 +19,7 @@ from pathlib import Path from .agents import * from . import agents from .simulation import * -from .environment import Environment, EventedEnvironment +from .environment import Environment from .datacollection import SoilCollector from . import serialization from .utils import logger @@ -117,13 +117,13 @@ def main( ) parser.add_argument( "--max_time", - default="-1", + default="", help="Set maximum time for the simulation to run. ", ) parser.add_argument( "--max_steps", - default="-1", + default="", help="Set maximum number of steps for the simulation to run.", ) @@ -249,9 +249,12 @@ def main( if args.only_convert: print(sim.to_yaml()) continue - max_time = float(args.max_time) if args.max_time != "-1" else None - max_steps = float(args.max_steps) if args.max_steps != "-1" else None - res.append(sim.run(max_time=max_time, max_steps=max_steps)) + d = {} + if args.max_time: + d["max_time"] = float(args.max_time) + if args.max_steps: + d["max_steps"] = int(args.max_steps) + res.append(sim.run(**d)) except Exception as ex: if args.pdb: diff --git a/soil/agents/__init__.py b/soil/agents/__init__.py index 4de10b7..0309191 100644 --- a/soil/agents/__init__.py +++ b/soil/agents/__init__.py @@ -1,107 +1,23 @@ from __future__ import annotations import logging -from collections import OrderedDict, defaultdict -from collections.abc import MutableMapping, Mapping, Set -from abc import ABCMeta -from copy import deepcopy, copy -from functools import partial, wraps -from itertools import islice, chain +from collections.abc import MutableMapping +from copy import deepcopy import inspect -import types import textwrap -import networkx as nx import warnings import sys -from typing import Any +from mesa import Agent as MesaAgent -from mesa import Agent as MesaAgent, Model -from typing import Dict, List +from .. import utils, time -from .. import serialization, network, utils, time, config +from .meta import MetaAgent IGNORED_FIELDS = ("model", "logger") -def decorate_generator_step(func, name): - @wraps(func) - def decorated(self): - while True: - if self._coroutine is None: - self._coroutine = func(self) - try: - if self._last_except: - val = self._coroutine.throw(self._last_except) - else: - val = self._coroutine.send(self._last_return) - except StopIteration as ex: - self._coroutine = None - val = ex.value - finally: - self._last_return = None - self._last_except = None - return float(val) if val is not None else val - return decorated - - -def decorate_normal_func(func, name): - @wraps(func) - def decorated(self): - val = func(self) - return float(val) if val is not None else val - return decorated - - -class MetaAgent(ABCMeta): - def __new__(mcls, name, bases, namespace): - defaults = {} - - # Re-use defaults from inherited classes - for i in bases: - if isinstance(i, MetaAgent): - defaults.update(i._defaults) - - new_nmspc = { - "_defaults": defaults, - } - - for attr, func in namespace.items(): - if attr == "step": - if inspect.isgeneratorfunction(func) or inspect.iscoroutinefunction(func): - func = decorate_generator_step(func, attr) - new_nmspc.update({ - "_last_return": None, - "_last_except": None, - "_coroutine": None, - }) - elif inspect.isasyncgenfunction(func): - raise ValueError("Illegal step function: {}. It probably mixes both async/await and yield".format(func)) - elif inspect.isfunction(func): - func = decorate_normal_func(func, attr) - else: - raise ValueError("Illegal step function: {}".format(func)) - new_nmspc[attr] = func - elif ( - isinstance(func, types.FunctionType) - or isinstance(func, property) - or isinstance(func, classmethod) - or attr[0] == "_" - ): - new_nmspc[attr] = func - elif attr == "defaults": - defaults.update(func) - elif inspect.isfunction(func): - new_nmspc[attr] = func - else: - defaults[attr] = copy(func) - - - # Add attributes for their use in the decorated functions - return super().__new__(mcls, name, bases, new_nmspc) - - class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent): """ A special type of Mesa Agent that: @@ -154,11 +70,11 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent): return hash(self.unique_id) def prob(self, probability): - return prob(probability, self.model.random) + return utils.prob(probability, self.model.random) @classmethod def w(cls, **kwargs): - return custom(cls, **kwargs) + return utils.custom(cls, **kwargs) # TODO: refactor to clean up mesa compatibility @property @@ -168,21 +84,6 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent): print(msg, file=sys.stderr) return self.unique_id - @classmethod - def from_dict(cls, model, attrs, warn_extra=True): - ignored = {} - args = {} - for k, v in attrs.items(): - if k in inspect.signature(cls).parameters: - args[k] = v - else: - ignored[k] = v - if ignored and warn_extra: - utils.logger.info( - f"Ignoring the following arguments for agent class { agent_class.__name__ }: { ignored }" - ) - return cls(model=model, **args) - def __getitem__(self, key): try: return getattr(self, key) @@ -302,399 +203,23 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent): return time.Delay(delay) -def prob(prob, random): - """ - A true/False uniform distribution with a given probability. - To be used like this: - - .. code-block:: python - - if prob(0.3): - do_something() - - """ - r = random.random() - return r < prob - - -def calculate_distribution(network_agents=None, agent_class=None): - """ - Calculate the threshold values (thresholds for a uniform distribution) - of an agent distribution given the weights of each agent type. - - The input has this form: :: - - [ - {'agent_class': 'agent_class_1', - 'weight': 0.2, - 'state': { - 'id': 0 - } - }, - {'agent_class': 'agent_class_2', - 'weight': 0.8, - 'state': { - 'id': 1 - } - } - ] - - In this example, 20% of the nodes will be marked as type - 'agent_class_1'. - """ - if network_agents: - network_agents = [ - deepcopy(agent) for agent in network_agents if not hasattr(agent, "id") - ] - elif agent_class: - network_agents = [{"agent_class": agent_class}] - else: - raise ValueError("Specify a distribution or a default agent type") - - # Fix missing weights and incompatible types - for x in network_agents: - x["weight"] = float(x.get("weight", 1)) - - # Calculate the thresholds - total = sum(x["weight"] for x in network_agents) - acc = 0 - for v in network_agents: - if "ids" in v: - continue - upper = acc + (v["weight"] / total) - v["threshold"] = [acc, upper] - acc = upper - return network_agents - - -def _serialize_type(agent_class, known_modules=[], **kwargs): - if isinstance(agent_class, str): - return agent_class - known_modules += ["soil.agents"] - return serialization.serialize(agent_class, known_modules=known_modules, **kwargs)[ - 1 - ] # Get the name of the class - - -def _deserialize_type(agent_class, known_modules=[]): - if not isinstance(agent_class, str): - return agent_class - known = known_modules + ["soil.agents", "soil.agents.custom"] - agent_class = serialization.deserializer(agent_class, known_modules=known) - return agent_class - - -class AgentView(Mapping, Set): - """A lazy-loaded list of agents.""" - - __slots__ = ("_agents",) - - def __init__(self, agents): - self._agents = agents - - def __getstate__(self): - return {"_agents": self._agents} - - def __setstate__(self, state): - self._agents = state["_agents"] - - # Mapping methods - def __len__(self): - return len(self._agents) - - def __iter__(self): - yield from self._agents.values() - - def __getitem__(self, agent_id): - if isinstance(agent_id, slice): - raise ValueError(f"Slicing is not supported") - if agent_id in self._agents: - return self._agents[agent_id] - raise ValueError(f"Agent {agent_id} not found") - - def filter(self, *args, **kwargs): - yield from filter_agents(self._agents, *args, **kwargs) - - def one(self, *args, **kwargs): - return next(filter_agents(self._agents, *args, **kwargs)) - - def __call__(self, *args, **kwargs): - return list(self.filter(*args, **kwargs)) - - def __contains__(self, agent_id): - return agent_id in self._agents - - def __str__(self): - return str(list(unique_id for unique_id in self.keys())) - - def __repr__(self): - return f"{self.__class__.__name__}({self})" - - -def filter_agents( - agents: dict, - *id_args, - unique_id=None, - state_id=None, - agent_class=None, - ignore=None, - state=None, - limit=None, - **kwargs, -): - """ - Filter agents given as a dict, by the criteria given as arguments (e.g., certain type or state id). - """ - assert isinstance(agents, dict) - - ids = [] - - if unique_id is not None: - if isinstance(unique_id, list): - ids += unique_id - else: - ids.append(unique_id) - - if id_args: - ids += id_args - - if ids: - f = (agents[aid] for aid in ids if aid in agents) - else: - f = agents.values() - - if state_id is not None and not isinstance(state_id, (tuple, list)): - state_id = tuple([state_id]) - - if agent_class is not None: - agent_class = _deserialize_type(agent_class) - try: - agent_class = tuple(agent_class) - except TypeError: - agent_class = tuple([agent_class]) - - if ignore: - f = filter(lambda x: x not in ignore, f) - - if state_id is not None: - f = filter(lambda agent: agent.get("state_id", None) in state_id, f) - - if agent_class is not None: - f = filter(lambda agent: isinstance(agent, agent_class), f) - - state = state or dict() - state.update(kwargs) - - for k, vs in state.items(): - if not isinstance(vs, list): - vs = [vs] - f = filter(lambda agent: any(getattr(agent, k, None) == v for v in vs), f) - - if limit is not None: - f = islice(f, limit) - - yield from f - - -def from_config( - cfg: config.AgentConfig, random, topology: nx.Graph = None -) -> List[Dict[str, Any]]: - """ - This function turns an agentconfig into a list of individual "agent specifications", which are just a dictionary - with the parameters that the environment will use to construct each agent. - - This function does NOT return a list of agents, mostly because some attributes to the agent are not known at the - time of calling this function, such as `unique_id`. - """ - default = cfg or config.AgentConfig() - if not isinstance(cfg, config.AgentConfig): - cfg = config.AgentConfig(**cfg) - - agents = [] - - assigned_total = 0 - assigned_network = 0 - - if cfg.fixed is not None: - agents, assigned_total, assigned_network = _from_fixed( - cfg.fixed, topology=cfg.topology, default=cfg - ) - - n = cfg.n - - if cfg.distribution: - topo_size = len(topology) if topology else 0 - - networked = [] - total = [] - - for d in cfg.distribution: - if d.strategy == config.Strategy.topology: - topo = d.topology if ("topology" in d.__fields_set__) else cfg.topology - if not topo: - raise ValueError( - 'The "topology" strategy only works if the topology parameter is set to True' - ) - if not topo_size: - raise ValueError( - f"Topology does not have enough free nodes to assign one to the agent" - ) - - networked.append(d) - - if d.strategy == config.Strategy.total: - if not cfg.n: - raise ValueError( - 'Cannot use the "total" strategy without providing the total number of agents' - ) - total.append(d) - - if networked: - new_agents = _from_distro( - networked, - n=topo_size - assigned_network, - topology=topo, - default=cfg, - random=random, - ) - assigned_total += len(new_agents) - assigned_network += len(new_agents) - agents += new_agents - - if total: - remaining = n - assigned_total - agents += _from_distro(total, n=remaining, default=cfg, random=random) - - if assigned_network < topo_size: - utils.logger.warn( - f"The total number of agents does not match the total number of nodes in " - "every topology. This may be due to a definition error: assigned: " - f"{ assigned } total size: { topo_size }" - ) - - return agents - - -def _from_fixed( - lst: List[config.FixedAgentConfig], - topology: bool, - default: config.SingleAgentConfig, -) -> List[Dict[str, Any]]: - agents = [] - - counts_total = 0 - counts_network = 0 - - for fixed in lst: - agent = {} - if default: - agent = default.state.copy() - agent.update(fixed.state) - cls = serialization.deserialize( - fixed.agent_class or (default and default.agent_class) - ) - agent["agent_class"] = cls - topo = ( - fixed.topology - if ("topology" in fixed.__fields_set__) - else topology or default.topology - ) - - if topo: - agent["topology"] = True - counts_network += 1 - if not fixed.hidden: - counts_total += 1 - agents.append(agent) - - return agents, counts_total, counts_network - - -def _from_distro( - distro: List[config.AgentDistro], - n: int, - default: config.SingleAgentConfig, - random, - topology: str = None -) -> List[Dict[str, Any]]: - - agents = [] - - if n is None: - if any(lambda dist: dist.n is None, distro): - raise ValueError( - "You must provide a total number of agents, or the number of each type" - ) - n = sum(dist.n for dist in distro) - - weights = list(dist.weight if dist.weight is not None else 1 for dist in distro) - minw = min(weights) - norm = list(weight / minw for weight in weights) - total = sum(norm) - chunk = n // total - - # random.choices would be enough to get a weighted distribution. But it can vary a lot for smaller k - # So instead we calculate our own distribution to make sure the actual ratios are close to what we would expect - - # Calculate how many times each has to appear - indices = list( - chain.from_iterable([idx] * int(n * chunk) for (idx, n) in enumerate(norm)) - ) - - # Complete with random agents following the original weight distribution - if len(indices) < n: - indices += random.choices( - list(range(len(distro))), - weights=[d.weight for d in distro], - k=n - len(indices), - ) - - # Deserialize classes for efficiency - classes = list( - serialization.deserialize(i.agent_class or default.agent_class) for i in distro - ) - - # Add them in random order - random.shuffle(indices) - - for idx in indices: - d = distro[idx] - agent = d.state.copy() - cls = classes[idx] - agent["agent_class"] = cls - if default: - agent.update(default.state) - topology = ( - d.topology - if ("topology" in d.__fields_set__) - else topology or default.topology - ) - if topology: - agent["topology"] = topology - agents.append(agent) - - return agents +class Noop(BaseAgent): + def step(self): + return from .network_agents import * from .fsm import * from .evented import * -from typing import Optional +from .view import * -class Agent(NetworkAgent, FSM, EventedAgent): - """Default agent class, has both network and event capabilities""" - - -from ..environment import NetworkEnvironment +class Agent(FSM, EventedAgent, NetworkAgent): + """Default agent class, has network, FSM and event capabilities""" +# Additional types of agents from .BassModel import * from .IndependentCascadeModel import * from .SISaModel import * -from .CounterModel import * - - -def custom(cls, **kwargs): - """Create a new class from a template class and keyword arguments""" - return type(cls.__name__, (cls,), kwargs) +from .CounterModel import * \ No newline at end of file diff --git a/soil/agents/evented.py b/soil/agents/evented.py index 5cf69dc..d5db284 100644 --- a/soil/agents/evented.py +++ b/soil/agents/evented.py @@ -1,58 +1,34 @@ from . import BaseAgent from ..events import Message, Tell, Ask, TimedOut +from .. import environment, events from functools import partial from collections import deque from types import coroutine +# from soilent import Scheduler + class EventedAgent(BaseAgent): + # scheduler_class = Scheduler def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._inbox = deque() - self._processed = 0 + assert isinstance(self.model, environment.EventedEnvironment), "EventedAgent requires an EventedEnvironment" + self.model.register(self) - def on_receive(self, *args, **kwargs): - pass + def received(self, **kwargs): + return self.model.received(self, **kwargs) - @coroutine - def received(self, expiration=None, timeout=60, delay=1, process=True): - if not expiration: - expiration = self.now + timeout - while self.now < expiration: - if self._inbox: - msgs = self._inbox - if process: - self.process_messages() - return msgs - yield self.delay(delay) - raise TimedOut("No message received") + def tell(self, msg, **kwargs): + return self.model.tell(msg, recipient=self, **kwargs) - def tell(self, msg, sender=None): - self._inbox.append(Tell(timestamp=self.now, payload=msg, sender=sender)) + def broadcast(self, msg, **kwargs): + return self.model.broadcast(msg, sender=self, **kwargs) - @coroutine - def ask(self, msg, expiration=None, timeout=None, delay=1): - ask = Ask(timestamp=self.now, payload=msg, sender=self) - self._inbox.append(ask) - expiration = float("inf") if timeout is None else self.now + timeout - while self.now < expiration: - if ask.reply: - return ask.reply - yield self.delay(delay) - raise TimedOut("No reply received") + def ask(self, msg, **kwargs): + return self.model.ask(msg, recipient=self, **kwargs) def process_messages(self): - valid = list() - for msg in self._inbox: - self._processed += 1 - if msg.expired(self.now): - continue - valid.append(msg) - reply = self.on_receive(msg.payload, sender=msg.sender) - if isinstance(msg, Ask): - msg.reply = reply - self._inbox.clear() - return valid + return self.model.process_messages(self.model.inbox_for(self)) Evented = EventedAgent diff --git a/soil/agents/fsm.py b/soil/agents/fsm.py index 85a5fb5..dca43bc 100644 --- a/soil/agents/fsm.py +++ b/soil/agents/fsm.py @@ -140,11 +140,19 @@ class FSM(BaseAgent, metaclass=MetaFSM): self._set_state(value) def step(self): + if self._state is None: + if len(self._states) == 1: + raise Exception("Agent class has no valid states: {}. Make sure to define states or define a custom step function".format(self.__class__.__name__)) + else: + raise Exception("Invalid state (None) for agent {}".format(self)) + self._check_alive() next_state = yield from self._state.step(self) try: next_state, when = next_state + self._set_state(next_state) + return when except (TypeError, ValueError) as ex: try: self._set_state(next_state) @@ -152,9 +160,6 @@ class FSM(BaseAgent, metaclass=MetaFSM): except ValueError: return next_state - self._set_state(next_state) - return when - def _set_state(self, state): if state is None: return diff --git a/soil/agents/meta.py b/soil/agents/meta.py new file mode 100644 index 0000000..4d4c5b2 --- /dev/null +++ b/soil/agents/meta.py @@ -0,0 +1,87 @@ +from abc import ABCMeta +from copy import copy +from functools import wraps +from .. import time + +import types +import inspect + +def decorate_generator_step(func, name): + @wraps(func) + def decorated(self): + if not self.alive: + return time.INFINITY + + if self._coroutine is None: + self._coroutine = func(self) + try: + if self._last_except: + val = self._coroutine.throw(self._last_except) + else: + val = self._coroutine.send(self._last_return) + except StopIteration as ex: + self._coroutine = None + val = ex.value + finally: + self._last_return = None + self._last_except = None + return float(val) if val is not None else val + return decorated + + +def decorate_normal_step(func, name): + @wraps(func) + def decorated(self): + # if not self.alive: + # return time.INFINITY + val = func(self) + return float(val) if val is not None else val + return decorated + + +class MetaAgent(ABCMeta): + def __new__(mcls, name, bases, namespace): + defaults = {} + + # Re-use defaults from inherited classes + for i in bases: + if isinstance(i, MetaAgent): + defaults.update(i._defaults) + + new_nmspc = { + "_defaults": defaults, + } + + for attr, func in namespace.items(): + if attr == "step": + if inspect.isgeneratorfunction(func) or inspect.iscoroutinefunction(func): + func = decorate_generator_step(func, attr) + new_nmspc.update({ + "_last_return": None, + "_last_except": None, + "_coroutine": None, + }) + elif inspect.isasyncgenfunction(func): + raise ValueError("Illegal step function: {}. It probably mixes both async/await and yield".format(func)) + elif inspect.isfunction(func): + func = decorate_normal_step(func, attr) + else: + raise ValueError("Illegal step function: {}".format(func)) + new_nmspc[attr] = func + elif ( + isinstance(func, types.FunctionType) + or isinstance(func, property) + or isinstance(func, classmethod) + or attr[0] == "_" + ): + new_nmspc[attr] = func + elif attr == "defaults": + defaults.update(func) + elif inspect.isfunction(func): + new_nmspc[attr] = func + else: + defaults[attr] = copy(func) + + + # Add attributes for their use in the decorated functions + return super().__new__(mcls, name, bases, new_nmspc) \ No newline at end of file diff --git a/soil/agents/network_agents.py b/soil/agents/network_agents.py index 2d0a948..6f5befb 100644 --- a/soil/agents/network_agents.py +++ b/soil/agents/network_agents.py @@ -1,9 +1,11 @@ from . import BaseAgent +from .. import environment class NetworkAgent(BaseAgent): def __init__(self, *args, topology=None, init=True, node_id=None, **kwargs): super().__init__(*args, init=False, **kwargs) + assert isinstance(self.model, environment.NetworkEnvironment), "NetworkAgent requires a NetworkEnvironment" self.G = topology or self.model.G assert self.G is not None, "Network agents should have a network" @@ -16,7 +18,7 @@ class NetworkAgent(BaseAgent): else: node_id = len(self.G) self.info(f"All nodes ({len(self.G)}) have an agent assigned, adding a new node to the graph for agent {self.unique_id}") - self.G.add_node(node_id) + self.G.add_node(node_id, find_unassigned=True) assert node_id is not None self.G.nodes[node_id]["agent"] = self self.node_id = node_id diff --git a/soil/agents/view.py b/soil/agents/view.py new file mode 100644 index 0000000..f91501c --- /dev/null +++ b/soil/agents/view.py @@ -0,0 +1,136 @@ +from collections.abc import Mapping, Set +from itertools import islice + + +class AgentView(Mapping, Set): + """A lazy-loaded list of agents.""" + + __slots__ = ("_agents", "agents_by_type") + + def __init__(self, agents, agents_by_type): + self._agents = agents + self.agents_by_type = agents_by_type + + def __getstate__(self): + return {"_agents": self._agents} + + def __setstate__(self, state): + self._agents = state["_agents"] + + # Mapping methods + def __len__(self): + return len(self._agents) + + def __iter__(self): + yield from self._agents.values() + + def __getitem__(self, agent_id): + if isinstance(agent_id, slice): + raise ValueError(f"Slicing is not supported") + if agent_id in self._agents: + return self._agents[agent_id] + raise ValueError(f"Agent {agent_id} not found") + + def filter(self, agent_class=None, include_subclasses=True, **kwargs): + if agent_class and self.agents_by_type: + if not include_subclasses: + return filter_agents(self.agents_by_type[agent_class], + **kwargs) + else: + d = {} + for k, v in self.agents_by_type.items(): + if (k == agent_class) or issubclass(k, agent_class): + d.update(v) + return filter_agents(d, **kwargs) + return filter_agents(self._agents, agent_class=agent_class, **kwargs) + + + def one(self, *args, **kwargs): + try: + return next(self.filter(*args, **kwargs)) + except StopIteration: + return None + + def __call__(self, *args, **kwargs): + return list(self.filter(*args, **kwargs)) + + def __contains__(self, agent_id): + return agent_id in self._agents + + def __str__(self): + return str(list(unique_id for unique_id in self.keys())) + + def __repr__(self): + return f"{self.__class__.__name__}({self})" + + +def filter_agents( + agents: dict, + *id_args, + unique_id=None, + state_id=None, + agent_class=None, + ignore=None, + state=None, + limit=None, + **kwargs, +): + """ + Filter agents given as a dict, by the criteria given as arguments (e.g., certain type or state id). + """ + assert isinstance(agents, dict) + + ids = [] + + if unique_id is not None: + if isinstance(unique_id, list): + ids += unique_id + else: + ids.append(unique_id) + + if id_args: + ids += id_args + + if ids: + f = list(agents[aid] for aid in ids if aid in agents) + else: + f = agents.values() + + if state_id is not None and not isinstance(state_id, (tuple, list)): + state_id = tuple([state_id]) + + if ignore: + f = filter(lambda x: x not in ignore, f) + + if state_id is not None: + f = filter(lambda agent: agent.get("state_id", None) in state_id, f) + + if agent_class is not None: + f = filter(lambda agent: isinstance(agent, agent_class), f) + + state = state or dict() + state.update(kwargs) + + for k, vs in state.items(): + if not isinstance(vs, list): + vs = [vs] + f = filter(lambda agent: any(getattr(agent, k, None) == v for v in vs), f) + + if limit is not None: + f = islice(f, limit) + + return AgentResult(f) + +class AgentResult: + def __init__(self, iterator): + self.iterator = iterator + + def limit(self, limit): + self.iterator = islice(self.iterator, limit) + return self + + def __iter__(self): + return iter(self.iterator) + + def __next__(self): + return next(self.iterator) \ No newline at end of file diff --git a/soil/analysis.py b/soil/analysis.py index ab7164c..0312a28 100644 --- a/soil/analysis.py +++ b/soil/analysis.py @@ -43,15 +43,14 @@ def read_sql(fpath=None, name=None, include_agents=False): with engine.connect() as conn: env = pd.read_sql_table("env", con=conn, index_col="step").reset_index().set_index([ - "simulation_id", "params_id", - "iteration_id", "step" + "params_id", "iteration_id", "step" ]) - agents = pd.read_sql_table("agents", con=conn, index_col=["simulation_id", "params_id", "iteration_id", "step", "agent_id"]) + agents = pd.read_sql_table("agents", con=conn, index_col=["params_id", "iteration_id", "step", "agent_id"]) config = pd.read_sql_table("configuration", con=conn, index_col="simulation_id") - parameters = pd.read_sql_table("parameters", con=conn, index_col=["iteration_id", "params_id", "simulation_id"]) - try: - parameters = parameters.pivot(columns="key", values="value") - except Exception as e: - print(f"warning: coult not pivot parameters: {e}") + parameters = pd.read_sql_table("parameters", con=conn, index_col=["simulation_id", "params_id", "iteration_id"]) + # try: + # parameters = parameters.pivot(columns="key", values="value") + # except Exception as e: + # print(f"warning: coult not pivot parameters: {e}") return Results(config, parameters, env, agents) diff --git a/soil/environment.py b/soil/environment.py index 5188739..4ad8a76 100644 --- a/soil/environment.py +++ b/soil/environment.py @@ -1,20 +1,16 @@ from __future__ import annotations import os -import sqlite3 -import math +import sys import logging -import inspect from typing import Any, Callable, Dict, Optional, Union, List, Type -from collections import namedtuple -from time import time as current_time -from copy import deepcopy - +from types import coroutine import networkx as nx -from mesa import Model, Agent + +from mesa import Model from . import agents as agentmod, datacollection, utils, time, network, events @@ -114,14 +110,14 @@ class BaseEnvironment(Model): @property def agents(self): - return agentmod.AgentView(self.schedule._agents) + return agentmod.AgentView(self.schedule._agents, getattr(self.schedule, "agents_by_type", None)) def agent(self, *args, **kwargs): - return agentmod.AgentView(self.schedule._agents).one(*args, **kwargs) + return agentmod.AgentView(self.schedule._agents, self.schedule.agents_by_type).one(*args, **kwargs) def count_agents(self, *args, **kwargs): return sum(1 for i in self.agents(*args, **kwargs)) - + def agent_df(self, steps=False): df = self.datacollector.get_agent_vars_dataframe() if steps: @@ -205,11 +201,18 @@ class BaseEnvironment(Model): func = name self.datacollector._new_model_reporter(name, func) - def add_agent_reporter(self, name, reporter=None, agent_type=None): - if not agent_type and not reporter: + def add_agent_reporter(self, name, reporter=None, agent_class=None, *, agent_type=None): + if agent_type: + print("agent_type is deprecated, use agent_class instead", file=sys.stderr) + agent_class = agent_type or agent_class + if not reporter and not agent_class: reporter = name - elif agent_type: - reporter = lambda a: reporter(a) if isinstance(a, agent_type) else None + if agent_class: + if reporter: + _orig = reporter + else: + _orig = lambda a: getattr(a, name) + reporter = lambda a: (_orig(a) if isinstance(a, agent_class) else None) self.datacollector._new_agent_reporter(name, reporter) @classmethod @@ -331,15 +334,16 @@ class NetworkEnvironment(BaseEnvironment): if getattr(agent, "alive", True): yield agent - def add_node(self, agent_class, unique_id=None, node_id=None, **kwargs): + def add_node(self, agent_class, unique_id=None, node_id=None, find_unassigned=False, **kwargs): if unique_id is None: unique_id = self.next_id() if node_id is None: - node_id = network.find_unassigned( - G=self.G, shuffle=True, random=self.random - ) + if find_unassigned: + node_id = network.find_unassigned( + G=self.G, shuffle=True, random=self.random + ) if node_id is None: - node_id = f"node_for_{unique_id}" + node_id = f"Node_for_agent_{unique_id}" if node_id not in self.G.nodes: self.G.add_node(node_id) @@ -409,27 +413,84 @@ class NetworkEnvironment(BaseEnvironment): class EventedEnvironment(BaseEnvironment): - def broadcast(self, msg, sender=None, expiration=None, ttl=None, **kwargs): - for agent in self.agents(**kwargs): - if agent == sender: + + def __init__(self, *args, **kwargs): + self._inbox = dict() + super().__init__(*args, **kwargs) + + def register(self, agent): + self._inbox[agent.unique_id] = [] + + def inbox_for(self, agent): + try: + return self._inbox[agent.unique_id] + except KeyError: + raise ValueError(f"Trying to access inbox for unregistered agent: {agent} (class: {type(agent)}). " + "Make sure your agent is of type EventedAgent and it is registered with the environment.") + + @coroutine + def received(self, agent, expiration=None, timeout=60, delay=1): + if not expiration: + expiration = self.now + timeout + inbox = self.inbox_for(agent) + if inbox: + return self.process_messages(inbox) + while self.now < expiration: + # TODO: this wakes the agent up at every step. It would be better to wait until timeout (or inf) + # and if a message is received before that, reschedule the agent when + if inbox: + return self.process_messages(inbox) + yield time.Delay(delay) + raise events.TimedOut("No message received") + + def tell(self, msg, sender, recipient, expiration=None, timeout=None, **kwargs): + if expiration is None: + expiration = float("inf") if timeout is None else self.now + timeout + self.inbox_for(recipient).append( + events.Tell(timestamp=self.now, + payload=msg, + sender=sender, + expiration=expiration, + **kwargs)) + + def broadcast(self, msg, sender, ttl=None, expiration=None, agent_class=None): + expiration = expiration if ttl is None else self.now + ttl + # This only works for Soil environments. Mesa agents do not have an `agents` method + sender_id = sender.unique_id + for (agent_id, inbox) in self._inbox.items(): + if agent_id == sender_id: continue - self.logger.debug(f"Telling {repr(agent)}: {msg} ttl={ttl}") - try: - inbox = agent._inbox - except AttributeError: - self.logger.info( - f"Agent {agent.unique_id} cannot receive events because it does not have an inbox" - ) + if agent_class and not isinstance(self.agents(unique_id=agent_id), agent_class): continue - # Allow for AttributeError exceptions in this part of the code + self.logger.debug(f"Telling {agent_id}: {msg} ttl={ttl}") inbox.append( events.Tell( payload=msg, sender=sender, - expiration=expiration if ttl is None else self.now + ttl, + expiration=expiration, ) ) + @coroutine + def ask(self, msg, recipient, sender=None, expiration=None, timeout=None, delay=1): + ask = events.Ask(timestamp=self.now, payload=msg, sender=sender) + self.inbox_for(recipient).append(ask) + expiration = float("inf") if timeout is None else self.now + timeout + while self.now < expiration: + if ask.reply: + return ask.reply + yield time.Delay(delay) + raise events.TimedOut("No reply received") -class Environment(NetworkEnvironment, EventedEnvironment): - """Default environment class, has both network and event capabilities""" + def process_messages(self, inbox): + valid = list() + for msg in inbox: + if msg.expired(self.now): + continue + valid.append(msg) + inbox.clear() + return valid + + +class Environment(EventedEnvironment, NetworkEnvironment): + pass \ No newline at end of file diff --git a/soil/events.py b/soil/events.py index 275d222..f5baec5 100644 --- a/soil/events.py +++ b/soil/events.py @@ -18,7 +18,6 @@ class Message: def expired(self, when): return self.expiration is not None and self.expiration < when - class Reply(Message): source: Message @@ -28,7 +27,9 @@ class Ask(Message): class Tell(Message): - pass + def __post_init__(self): + assert self.sender is not None, "Tell requires a sender" + class TimedOut(Exception): diff --git a/soil/exporters.py b/soil/exporters.py index 8986453..ce1964b 100644 --- a/soil/exporters.py +++ b/soil/exporters.py @@ -1,6 +1,7 @@ import os import sys from time import time as current_time +from datetime import datetime from io import BytesIO from textwrap import dedent, indent @@ -86,20 +87,21 @@ class Exporter: pass return open_or_reuse(f, mode=mode, backup=self.simulation.backup, **kwargs) - def get_dfs(self, env, **kwargs): + def get_dfs(self, env, params_id, **kwargs): yield from get_dc_dfs(env.datacollector, - simulation_id=self.simulation.id, + params_id, iteration_id=env.id, **kwargs) -def get_dc_dfs(dc, **kwargs): +def get_dc_dfs(dc, params_id, **kwargs): dfs = {} dfe = dc.get_model_vars_dataframe() dfe.index.rename("step", inplace=True) dfs["env"] = dfe + kwargs["params_id"] = params_id try: - dfa = dc.get_agent_vars_dataframe() + dfa = dc.get_agent_vars_dataframe() dfa.index.rename(["step", "agent_id"], inplace=True) dfs["agents"] = dfa except UserWarning: @@ -108,9 +110,13 @@ def get_dc_dfs(dc, **kwargs): dfs[table_name] = dc.get_table_dataframe(table_name) for (name, df) in dfs.items(): for (k, v) in kwargs.items(): - df[k] = v - df.set_index(["simulation_id", "iteration_id"], append=True, inplace=True) - + if v: + df[k] = v + else: + df[k] = pd.Series(dtype="object") + df.reset_index(inplace=True) + df.set_index(["params_id", "iteration_id"], inplace=True) + yield from dfs.items() @@ -129,17 +135,21 @@ class SQLite(Exporter): logger.info("Dumping results to %s", self.dbpath) if self.simulation.backup: try_backup(self.dbpath, remove=True) - + if self.simulation.overwrite: if os.path.exists(self.dbpath): os.remove(self.dbpath) - + + outdir = os.path.dirname(self.dbpath) + if outdir and not os.path.exists(outdir): + os.makedirs(outdir) + self.engine = create_engine(f"sqlite:///{self.dbpath}", echo=False) sim_dict = {k: serialize(v)[0] for (k,v) in self.simulation.to_dict().items()} sim_dict["simulation_id"] = self.simulation.id df = pd.DataFrame([sim_dict]) - df.to_sql("configuration", con=self.engine, if_exists="append") + df.reset_index().to_sql("configuration", con=self.engine, if_exists="append", index=False) def iteration_end(self, env, params, params_id, *args, **kwargs): if not self.dump: @@ -149,15 +159,28 @@ class SQLite(Exporter): with timer( "Dumping simulation {} iteration {}".format(self.simulation.name, env.id) ): - - pd.DataFrame([{"simulation_id": self.simulation.id, + d = {"simulation_id": self.simulation.id, "params_id": params_id, "iteration_id": env.id, - "key": k, - "value": serialize(v)[0]} for (k,v) in params.items()]).to_sql("parameters", con=self.engine, if_exists="append") + } + for (k,v) in params.items(): + d[k] = serialize(v)[0] + + pd.DataFrame([d]).reset_index().to_sql("parameters", + con=self.engine, + if_exists="append", + index=False) + pd.DataFrame([{ + "simulation_id": self.simulation.id, + "params_id": params_id, + "iteration_id": env.id, + }]).reset_index().to_sql("iterations", + con=self.engine, + if_exists="append", + index=False) for (t, df) in self.get_dfs(env, params_id=params_id): - df.to_sql(t, con=self.engine, if_exists="append") + df.reset_index().to_sql(t, con=self.engine, if_exists="append", index=False) class csv(Exporter): """Export the state of each environment (and its agents) a CSV file for the simulation""" @@ -226,9 +249,9 @@ class graphdrawing(Exporter): class summary(Exporter): """Print a summary of each iteration to sys.stdout""" - def iteration_end(self, env, *args, **kwargs): + def iteration_end(self, env, params_id, *args, **kwargs): msg = "" - for (t, df) in self.get_dfs(env): + for (t, df) in self.get_dfs(env, params_id): if not len(df): continue tabs = "\t" * 2 @@ -262,21 +285,5 @@ class YAML(Exporter): logger.info(f"Dumping simulation configuration to {self.outdir}") f.write(self.simulation.to_yaml()) -class default(Exporter): - """Default exporter. Writes sqlite results, as well as the simulation YAML""" - def __init__(self, *args, exporter_cls=[], **kwargs): - exporter_cls = exporter_cls or [YAML, SQLite] - self.inner = [cls(*args, **kwargs) for cls in exporter_cls] - - def sim_start(self, *args, **kwargs): - for exporter in self.inner: - exporter.sim_start(*args, **kwargs) - - def sim_end(self, *args, **kwargs): - for exporter in self.inner: - exporter.sim_end(*args, **kwargs) - - def iteration_end(self, *args, **kwargs): - for exporter in self.inner: - exporter.iteration_end(*args, **kwargs) +default = SQLite \ No newline at end of file diff --git a/soil/time.py b/soil/time.py index de76dcb..705fec9 100644 --- a/soil/time.py +++ b/soil/time.py @@ -1,7 +1,7 @@ from mesa.time import BaseScheduler from queue import Empty from heapq import heappush, heappop, heapreplace -from collections import deque +from collections import deque, defaultdict import math import logging @@ -27,6 +27,12 @@ class Delay: def __await__(self): return (yield self.delta) +class When: + def __init__(self, when): + raise Exception("The use of When is deprecated. Use the `Agent.at` and `Agent.delay` methods instead") +class Delta: + def __init__(self, delta): + raise Exception("The use of Delay is deprecated. Use the `Agent.at` and `Agent.delay` methods instead") class DeadAgent(Exception): pass @@ -46,27 +52,39 @@ class PQueueActivation(BaseScheduler): self._shuffle = shuffle self.logger = getattr(self.model, "logger", logger).getChild(f"time_{ self.model }") self.next_time = self.time + self.agents_by_type = defaultdict(dict) def add(self, agent: MesaAgent, when=None): if when is None: when = self.time else: when = float(when) - - self._schedule(agent, None, when) + agent_class = type(agent) + self.agents_by_type[agent_class][agent.unique_id] = agent super().add(agent) - - def _schedule(self, agent, when=None, replace=False): + self.add_callback(agent.step, when) + + def add_callback(self, callback, when=None, replace=False): if when is None: when = self.time + else: + when = float(when) if self._shuffle: key = (when, self.model.random.random()) else: - key = (when, agent.unique_id) + key = when if replace: - heapreplace(self._queue, (key, agent)) + heapreplace(self._queue, (key, callback)) else: - heappush(self._queue, (key, agent)) + heappush(self._queue, (key, callback)) + + def remove(self, agent): + del self._agents[agent.unique_id] + del self._agents[type(agent)][agent.unique_id] + for i, (key, callback) in enumerate(self._queue): + if callback == agent.step: + del self._queue[i] + break def step(self) -> None: """ @@ -87,18 +105,14 @@ class PQueueActivation(BaseScheduler): next_time = when break - try: - when = agent.step() or 1 - when += now - except DeadAgent: - heappop(self._queue) - continue + when = agent.step() or 1 if when == INFINITY: heappop(self._queue) continue + when += now - self._schedule(agent, when, replace=True) + self.add_callback(agent, when, replace=True) self.steps += 1 @@ -117,26 +131,42 @@ class TimedActivation(BaseScheduler): self._shuffle = shuffle self.logger = getattr(self.model, "logger", logger).getChild(f"time_{ self.model }") self.next_time = self.time + self.agents_by_type = defaultdict(dict) def add(self, agent: MesaAgent, when=None): + self.add_callback(agent.step, when) + agent_class = type(agent) + self.agents_by_type[agent_class][agent.unique_id] = agent + super().add(agent) + + def _find_loc(self, when=None): if when is None: when = self.time else: when = float(when) - self._schedule(agent, None, when) - super().add(agent) - - def _schedule(self, agent, when=None, replace=False): when = when or self.time pos = len(self._queue) for (ix, l) in enumerate(self._queue): if l[0] == when: - l[1].append(agent) - return + return l[1] if l[0] > when: pos = ix break - self._queue.insert(pos, (when, [agent])) + lst = [] + self._queue.insert(pos, (when, lst)) + return lst + + def add_callback(self, func, when=None, replace=False): + lst = self._find_loc(when) + lst.append(func) + + def add_bulk(self, funcs, when=None): + lst = self._find_loc(when) + lst.extend(funcs) + + def remove(self, agent): + del self._agents[agent.unique_id] + del self.agents_by_type[type(agent)][agent.unique_id] def step(self) -> None: """ @@ -157,20 +187,22 @@ class TimedActivation(BaseScheduler): bucket = self._queue.popleft()[1] if self._shuffle: self.model.random.shuffle(bucket) - for agent in bucket: - try: - when = agent.step() or 1 - when += now - except DeadAgent: - continue + next_batch = defaultdict(list) + for func in bucket: + when = func() or 1 if when != INFINITY: - self._schedule(agent, when, replace=True) + when += now + next_batch[when].append(func) + + for (when, bucket) in next_batch.items(): + self.add_bulk(bucket, when) self.steps += 1 if self._queue: self.time = self._queue[0][0] else: + self.model.running = False self.time = INFINITY diff --git a/soil/utils.py b/soil/utils.py index 0be4c40..7917b6d 100644 --- a/soil/utils.py +++ b/soil/utils.py @@ -157,4 +157,24 @@ def run_parallel(func, iterable, num_processes=1, **kwargs): def int_seed(seed: str): - return int.from_bytes(seed.encode(), "little") \ No newline at end of file + return int.from_bytes(seed.encode(), "little") + + +def prob(prob, random): + """ + A true/False uniform distribution with a given probability. + To be used like this: + + .. code-block:: python + + if prob(0.3): + do_something() + + """ + r = random.random() + return r < prob + + +def custom(cls, **kwargs): + """Create a new class from a template class and keyword arguments""" + return type(cls.__name__, (cls,), kwargs) \ No newline at end of file diff --git a/tests/test_agents.py b/tests/test_agents.py index 613e732..64e7c4c 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -20,13 +20,14 @@ class TestAgents(TestCase): assert ret == stime.INFINITY def test_die_raises_exception(self): - """A dead agent should raise an exception if it is stepped after death""" + """A dead agent should continue returning INFINITY after death""" d = Dead(unique_id=0, model=environment.Environment()) assert d.alive d.step() assert not d.alive - with pytest.raises(stime.DeadAgent): - d.step() + when = d.step() + assert not d.alive + assert when == stime.INFINITY def test_agent_generator(self): """ @@ -79,30 +80,28 @@ class TestAgents(TestCase): """ class BCast(agents.Evented): - pings_received = 0 + pings_received = [] - def step(self): - print(self.model.broadcast) - try: - self.model.broadcast("PING") - except Exception as ex: - print(ex) + async def step(self): + self.broadcast("PING") + print("PING sent") while True: - self.process_messages() - yield + msgs = await self.received() + self.pings_received += msgs - def on_receive(self, msg, sender=None): - self.pings_received += 1 + e = environment.Environment() - e = environment.EventedEnvironment() - - for i in range(10): + num_agents = 10 + for i in range(num_agents): e.add_agent(agent_class=BCast) e.step() - pings_received = lambda: [a.pings_received for a in e.agents] - assert sorted(pings_received()) == list(range(1, 11)) + # Agents are executed in order, so the first agent should have not received any messages + pings_received = lambda: [len(a.pings_received) for a in e.agents] + assert sorted(pings_received()) == list(range(0, num_agents)) e.step() - assert all(x == 10 for x in pings_received()) + # After the second step, every agent should have received a broadcast from every other agent + received = pings_received() + assert all(x == (num_agents - 1) for x in received) def test_ask_messages(self): """ @@ -140,17 +139,16 @@ class TestAgents(TestCase): print("NOT sending ping") print("Checking msgs") # Do not block if we have already received a PING - if not self.process_messages(): - yield from self.received() - print("done") + msgs = yield from self.received() + for ping in msgs: + if ping.payload == "PING": + ping.reply = "PONG" + pongs.append(self.now) + else: + raise Exception("This should never happen") - def on_receive(self, msg, sender=None): - if msg == "PING": - pongs.append(self.now) - return "PONG" - raise Exception("This should never happen") - e = environment.EventedEnvironment(schedule_class=stime.OrderedTimedActivation) + e = environment.Environment(schedule_class=stime.OrderedTimedActivation) for i in range(2): e.add_agent(agent_class=Ping) assert e.now == 0 @@ -372,4 +370,24 @@ class TestAgents(TestCase): assert a.my_state == 5 model.step() assert a.now == 17 - assert a.my_state == 5 \ No newline at end of file + assert a.my_state == 5 + + def test_send_nonevent(self): + ''' + Sending a non-event should raise an error. + ''' + model = environment.Environment() + a = model.add_agent(agents.Noop) + class TestAgent(agents.Agent): + @agents.state(default=True) + def one(self): + try: + a.tell(b, 1) + raise AssertionError('Should have raised an error.') + except AttributeError: + self.model.tell(1, sender=self, recipient=a) + + model.add_agent(TestAgent) + + with pytest.raises(ValueError): + model.step() \ No newline at end of file diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 01f7f0e..5232a53 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -81,7 +81,8 @@ class Exporters(TestCase): model=ConstantEnv, name="exporter_sim", exporters=[ - exporters.default, + exporters.YAML, + exporters.SQLite, exporters.csv, ], exporter_params={"copy_to": output}, diff --git a/tests/test_main.py b/tests/test_main.py index 83db5c2..ef8ad3b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -135,9 +135,9 @@ class TestMain(TestCase): def test_serialize_agent_class(self): """A class from soil.agents should be serialized without the module part""" - ser = agents._serialize_type(CustomAgent) + ser = serialization.serialize(CustomAgent, known_modules=["soil.agents"])[1] assert ser == "test_main.CustomAgent" - ser = agents._serialize_type(agents.BaseAgent) + ser = serialization.serialize(agents.BaseAgent, known_modules=["soil.agents"])[1] assert ser == "BaseAgent" pickle.dumps(ser) @@ -227,3 +227,29 @@ class TestMain(TestCase): for i in a: for j in b: assert {"a": i, "b": j} in configs + + def test_agent_reporters(self): + """An environment should be able to set its own reporters""" + class Noop2(agents.Noop): + pass + + e = Environment() + e.add_agent(agents.Noop) + e.add_agent(Noop2) + e.add_agent_reporter("now") + e.add_agent_reporter("base", lambda a: "base", agent_class=agents.Noop) + e.add_agent_reporter("subclass", lambda a:"subclass", agent_class=Noop2) + e.step() + + # Step 0 is not present because we added the reporters + # after initialization. + df = e.agent_df() + assert "now" in df.columns + assert "base" in df.columns + assert "subclass" in df.columns + assert df["now"][(0,0)] == 1 + assert df["now"][(0,1)] == 1 + assert df["base"][(0,0)] == "base" + assert df["base"][(0,1)] == "base" + assert df["subclass"][(0,0)] is None + assert df["subclass"][(0,1)] == "subclass" \ No newline at end of file