diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca9fc3..1d17271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.3 UNRELEASED] +## [0.30 UNRELEASED] ### Added * Simple debugging capabilities in `soil.debugging`, with a custom `pdb.Debugger` subclass that exposes commands to list agents and their status and set breakpoints on states (for FSM agents). Try it with `soil --debug ` * Ability to run diff --git a/examples/events_and_messages/README.md b/examples/events_and_messages/README.md new file mode 100644 index 0000000..c5b77df --- /dev/null +++ b/examples/events_and_messages/README.md @@ -0,0 +1,7 @@ +This example can be run like with command-line options, like this: + +```bash +python cars.py --level DEBUG -e summary --csv +``` + +This will set the `CSV` (save the agent and model data to a CSV) and `summary` (print the a summary of the data to stdout) exporters, and set the log level to DEBUG. diff --git a/examples/events_and_messages/cars.py b/examples/events_and_messages/cars.py index ecb5b17..e4fed18 100644 --- a/examples/events_and_messages/cars.py +++ b/examples/events_and_messages/cars.py @@ -1,22 +1,63 @@ +""" +This is an example of a simplified city, where there are Passengers and Drivers that can take those passengers +from their location to their desired location. + +An example scenario could play like the following: + +- Drivers start in the `wandering` state, where they wander around the city until they have been assigned a journey +- Passenger(1) tells every driver that it wants to request a Journey. +- Each driver receives the request. + If Driver(2) is interested in providing the Journey, it asks Passenger(1) to confirm that it accepts Driver(2)'s request +- When Passenger(1) accepts the request, two things happen: + - Passenger(1) changes its state to `driving_home` + - Driver(2) starts moving towards the origin of the Journey +- Once Driver(2) reaches the origin, it starts moving itself and Passenger(1) to the destination of the Journey +- When Driver(2) reaches the destination (carrying Passenger(1) along): + - Driver(2) starts wondering again + - Passenger(1) dies, and is removed from the simulation +- If there are no more passengers available in the simulation, Drivers die +""" from __future__ import annotations from soil import * from soil import events from mesa.space import MultiGrid -from enum import Enum +# More complex scenarios may use more than one type of message between objects. +# A common pattern is to use `enum.Enum` to represent state changes in a request. @dataclass class Journey: + """ + This represents a request for a journey. Passengers and drivers exchange this object. + + A journey may have a driver assigned or not. If the driver has not been assigned, this + object is considered a "request for a journey". + """ origin: (int, int) destination: (int, int) tip: float - passenger: Passenger = None + passenger: Passenger driver: Driver = None class City(EventedEnvironment): - def __init__(self, *args, n_cars=1, height=100, width=100, n_passengers=10, agents=None, **kwargs): + """ + An environment with a grid where drivers and passengers will be placed. + + The number of drivers and riders is configurable through its parameters: + + :param str n_cars: The total number of drivers to add + :param str n_passengers: The number of passengers in the simulation + :param list agents: Specific agents to use in the simulation. It overrides the `n_passengers` + and `n_cars` params. + :param int height: Height of the internal grid + :param int width: Width of the internal grid + """ + def __init__(self, *args, n_cars=1, n_passengers=10, + height=100, width=100, agents=None, + model_reporters=None, + **kwargs): self.grid = MultiGrid(width=width, height=height, torus=False) if agents is None: agents = [] @@ -24,53 +65,73 @@ class City(EventedEnvironment): agents.append({'agent_class': Driver}) for i in range(n_passengers): agents.append({'agent_class': Passenger}) - super().__init__(*args, agents=agents, **kwargs) + model_reporters = model_reporters or {'earnings': 'total_earnings', 'n_passengers': 'number_passengers'} + print('REPORTERS', model_reporters) + super().__init__(*args, agents=agents, model_reporters=model_reporters, **kwargs) for agent in self.agents: self.grid.place_agent(agent, (0, 0)) self.grid.move_to_empty(agent) + @property + def total_earnings(self): + return sum(d.earnings for d in self.agents(agent_class=Driver)) + + @property + def number_passengers(self): + return self.count_agents(agent_class=Passenger) + + class Driver(Evented, FSM): pos = None journey = None earnings = 0 def on_receive(self, msg, sender): + '''This is not a state. It will run (and block) every time check_messages is invoked''' if self.journey is None and isinstance(msg, Journey) and msg.driver is None: msg.driver = self self.journey = msg - @default_state - @state - def wandering(self): - target = None - self.check_passengers() - self.journey = None - while self.journey is None: - if target is None or not self.move_towards(target): - target = self.random.choice(self.model.grid.get_neighborhood(self.pos, moore=False)) - self.check_passengers() - self.check_messages() # This will call on_receive behind the scenes - yield Delta(30) - try: - self.journey = yield self.journey.passenger.ask(self.journey, timeout=60) - except events.TimedOut: - self.journey = None - return - return self.driving - def check_passengers(self): + '''If there are no more passengers, stop forever''' c = self.count_agents(agent_class=Passenger) self.info(f"Passengers left {c}") if not c: self.die() + @default_state + @state + def wandering(self): + '''Move around the city until a journey is accepted''' + target = None + self.check_passengers() + self.journey = None + while self.journey is None: # No potential journeys detected (see on_receive) + if target is None or not self.move_towards(target): + target = self.random.choice(self.model.grid.get_neighborhood(self.pos, moore=False)) + + self.check_passengers() + self.check_messages() # This will call on_receive behind the scenes, and the agent's status will be updated + yield Delta(30) # Wait at least 30 seconds before checking again + + try: + # Re-send the journey to the passenger, to confirm that we have been selected + self.journey = yield self.journey.passenger.ask(self.journey, timeout=60) + except events.TimedOut: + # No journey has been accepted. Try again + self.journey = None + return + + return self.driving + @state def driving(self): - #Approaching + '''The journey has been accepted. Pick them up and take them to their destination''' while self.move_towards(self.journey.origin): yield while self.move_towards(self.journey.destination, with_passenger=True): yield + self.earnings += self.journey.tip self.check_passengers() return self.wandering @@ -97,6 +158,14 @@ 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 `check_messages` is run''' + + if isinstance(msg, Journey): + self.journey = msg + return msg + + @default_state @state def asking(self): @@ -121,11 +190,6 @@ class Passenger(Evented, FSM): self.check_messages() return self.driving_home - def on_receive(self, msg, sender): - if isinstance(msg, Journey): - self.journey = msg - return msg - @state def driving_home(self): while self.pos[0] != self.journey.destination[0] or self.pos[1] != self.journey.destination[1]: @@ -134,7 +198,7 @@ class Passenger(Evented, FSM): self.die() -simulation = Simulation(model_class=City, model_params={'n_passengers': 2}) +simulation = Simulation(name='RideHailing', model_class=City, model_params={'n_passengers': 2}) if __name__ == "__main__": with easy(simulation) as s: diff --git a/soil/VERSION b/soil/VERSION index a87e255..129bfad 100644 --- a/soil/VERSION +++ b/soil/VERSION @@ -1 +1 @@ -0.30.0rc1 \ No newline at end of file +0.30.0rc2 \ No newline at end of file diff --git a/soil/__init__.py b/soil/__init__.py index b6b62ee..6382d29 100644 --- a/soil/__init__.py +++ b/soil/__init__.py @@ -153,8 +153,6 @@ def main( if output is None: output = args.output - - debug = debug or args.debug if args.pdb or debug: @@ -167,6 +165,10 @@ def main( if sim: logger.info("Loading simulation instance") + sim.dry_run = args.dry_run + sim.exporters = exporters + sim.parallel = parallel + sim.outdir = output sims = [sim, ] else: logger.info("Loading config file: {}".format(args.file)) @@ -231,7 +233,7 @@ def main( @contextmanager def easy(cfg, pdb=False, debug=False, **kwargs): try: - yield main(cfg, **kwargs)[0] + yield main(cfg, debug=debug, pdb=pdb, **kwargs)[0] except Exception as e: if os.environ.get("SOIL_POSTMORTEM"): from .debugging import post_mortem