diff --git a/examples/agriculture_model/__init__.py b/examples/agriculture_model/__init__.py new file mode 100644 index 000000000..ec2ef8e0f --- /dev/null +++ b/examples/agriculture_model/__init__.py @@ -0,0 +1 @@ +import examples.agriculture_model.tools diff --git a/examples/agriculture_model/agent.py b/examples/agriculture_model/agent.py new file mode 100644 index 000000000..018070093 --- /dev/null +++ b/examples/agriculture_model/agent.py @@ -0,0 +1,132 @@ +from enum import Enum + +from mesa_llm.llm_agent import LLMAgent +from mesa_llm.memory.st_lt_memory import STLTMemory +from mesa_llm.tools.tool_manager import ToolManager + +FARMER_TOOL_MANAGER = ToolManager() + + +class CropState(Enum): + IDLE = "IDLE" + PLANTED = "PLANTED" + GROWING = "GROWING" + READY = "READY" + + +class FarmerAgent(LLMAgent): + def __init__( + self, + model, + reasoning, + llm_model, + system_prompt, + vision, + internal_state, + step_prompt, + ): + super().__init__( + model=model, + reasoning=reasoning, + llm_model=llm_model, + system_prompt=system_prompt, + vision=vision, + internal_state=internal_state, + step_prompt=step_prompt, + ) + + self.tool_manager = FARMER_TOOL_MANAGER + self.land_size = self.random.randint(1, 5) + self.wealth = self.random.randint(1000, 50000) + self._base_internal_state = list(self.internal_state) + + self.crop_type = self.random.choice(["wheat", "rice", "maize"]) + self.crop_state = CropState.IDLE + + self.plant_date = None + self.harvest_date = None + self.fertilizer = 0.0 + + self.yield_output = 0.0 + self.profit = 0.0 + + self.memory = STLTMemory( + agent=self, + llm_model=llm_model, + display=True, + ) + + def observe_environment(self): + self.internal_state = [ + *self._base_internal_state, + f"My land size is {self.land_size}", + f"On a scale of 1000 - 50000, my wealth is {self.wealth}", + f"My crop type is {self.crop_type}", + f"My crop state is {self.crop_state.value}", + f"Rainfall condition is {self.model.rainfall}", + ] + + def update_crop_state(self): + if ( + self.crop_state in {CropState.PLANTED, CropState.GROWING} + and self.harvest_date is not None + and self.model.current_day >= self.harvest_date + ): + self.crop_state = CropState.READY + + def decide(self): + observation = self.generate_obs() + + prompt = f""" + You are a farmer. + wealth: {self.wealth} + Crop type: {self.crop_type} + Crop state: {self.crop_state} + land size: {self.land_size} + + Decide actions: + - plant_crop(days_to_harvest) + - apply_fertilizer(level) + - harvest_crop() + - speak_to() + + Use tools smartly. + """ + + plan = self.reasoning.plan( + prompt=prompt, + obs=observation, + selected_tools=[ + "plant_crop", + "apply_fertilizer", + "harvest_crop", + "speak_to", + ], + ) + + self.apply_plan(plan) + + def compute_yield(self): + base_yield = 1000 * self.land_size + + if self.model.rainfall == "LOW": + rain_factor = 0.7 + elif self.model.rainfall == "HIGH": + rain_factor = 1.3 + else: + rain_factor = 1.0 + + fert_factor = 1 + 0.3 * self.fertilizer + noise = self.random.uniform(0.9, 1.1) + + self.profit = 0 + + self.yield_output = base_yield * rain_factor * fert_factor * noise + price = self.model.market_price[self.crop_type] + self.profit += self.yield_output * price + + def step(self): + self.observe_environment() + self.update_crop_state() + self.decide() + self.update_crop_state() diff --git a/examples/agriculture_model/app.py b/examples/agriculture_model/app.py new file mode 100644 index 000000000..fdbfbb590 --- /dev/null +++ b/examples/agriculture_model/app.py @@ -0,0 +1,126 @@ +import logging +import warnings + +from dotenv import load_dotenv +from mesa.visualization import ( + Slider, + SolaraViz, + make_plot_component, + make_space_component, +) + +from examples.agriculture_model.agent import CropState, FarmerAgent +from examples.agriculture_model.model import FarmerModel +from mesa_llm.parallel_stepping import enable_automatic_parallel_stepping +from mesa_llm.reasoning.react import ReActReasoning + +warnings.filterwarnings( + "ignore", + category=UserWarning, + module="pydantic.main", + message=r".*Pydantic serializer warnings.*", +) +logging.getLogger("pydantic").setLevel(logging.ERROR) + +enable_automatic_parallel_stepping() +load_dotenv() + + +CROP_COLORS = { + CropState.IDLE: "#D7C7A3", + CropState.PLANTED: "#A3D977", + CropState.GROWING: "#6FBF73", + CropState.READY: "#FFBD66", +} + + +model_params = { + "initial_farmers": Slider("Number of Farmers", 10, 1, 50, 1), + "rainfall": Slider("Rainfall Level", 0.5, 0.0, 1.0, 0.1), + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "width": 20, + "height": 20, + "reasoning": ReActReasoning, + "llm_model": "ollama/llama3.1:latest", + "vision": 3, + "parallel_stepping": False, +} + +model = FarmerModel( + initial_farmers=10, + rainfall=0.5, + width=model_params["width"], + height=model_params["height"], + reasoning=model_params["reasoning"], + llm_model=model_params["llm_model"], + vision=model_params["vision"], + seed=model_params["seed"]["value"], + parallel_stepping=model_params["parallel_stepping"], +) + + +def farmer_portrayal(agent): + if agent is None: + return None + + portrayal = {"size": 50} + + if isinstance(agent, FarmerAgent): + portrayal["color"] = CROP_COLORS.get(agent.crop_state, "#888888") + + return portrayal + + +def post_process(ax): + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) + ax.get_figure().set_size_inches(8, 8) + + +space_component = make_space_component( + farmer_portrayal, + post_process=post_process, + draw_grid=False, +) + +crop_chart = make_plot_component( + { + "Rice": "#4CAF50", + "Wheat": "#FFC107", + "Maize": "#FF5722", + } +) + +profit_chart = make_plot_component( + { + "Total Profit": "#2196F3", + "Average Profit": "#9C27B0", + } +) + +state_chart = make_plot_component( + { + "Planted": "#A3D977", + "Growing": "#6FBF73", + "Ready": "#FFBD66", + "Idle": "#D7C7A3", + } +) + + +page = SolaraViz( + model, + components=[ + space_component, + crop_chart, + profit_chart, + state_chart, + ], + model_params=model_params, + name="Agriculture Decision Model (Mesa-LLM)", +) diff --git a/examples/agriculture_model/model.py b/examples/agriculture_model/model.py new file mode 100644 index 000000000..fc3d0201f --- /dev/null +++ b/examples/agriculture_model/model.py @@ -0,0 +1,135 @@ +from datetime import datetime, timedelta + +from mesa import Model +from mesa.datacollection import DataCollector +from mesa.space import MultiGrid + +from examples.agriculture_model.agent import CropState, FarmerAgent +from mesa_llm.reasoning.reasoning import Reasoning +from mesa_llm.recording.record_model import record_model + + +@record_model(output_dir="recordings") +class FarmerModel(Model): + def __init__( + self, + initial_farmers: int, + rainfall: int, + width: int, + height: int, + reasoning: type[Reasoning], + llm_model: str, + vision: int, + parallel_stepping=True, + seed=None, + ): + normalized_seed = None if seed in (None, "") else int(seed) + super().__init__(seed=normalized_seed) + self.width = width + self.height = height + self.parallel_stepping = parallel_stepping + self.grid = MultiGrid(width, height, torus=False) + + self.start_date = datetime(2024, 6, 1) + self.current_day = self.start_date + + self.rainfall = self._normalize_rainfall(rainfall=rainfall) + self.market_price = { + "wheat": 2.0, + "rice": 3.0, + "maize": 1.5, + } + + for _ in range(initial_farmers): + agent = FarmerAgent( + model=self, + reasoning=reasoning, + llm_model=llm_model, + system_prompt="You are a smart farmer.", + step_prompt="Decide actions.", + vision=vision, + internal_state=["hardworking"], + ) + x = self.random.randrange(self.grid.width) + y = self.random.randrange(self.grid.height) + self.grid.place_agent(agent, (x, y)) + + self.datacollector = DataCollector( + model_reporters={ + "Rice": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_type == "rice" + ), + "Wheat": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_type == "wheat" + ), + "Maize": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_type == "maize" + ), + "Total Profit": lambda m: sum( + a.profit for a in m.agents if isinstance(a, FarmerAgent) + ), + "Average Profit": lambda m: ( + sum(a.profit for a in m.agents if isinstance(a, FarmerAgent)) + / max( + 1, + sum(1 for a in m.agents if isinstance(a, FarmerAgent)), + ) + ), + "Planted": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_state == CropState.PLANTED + ), + "Growing": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_state == CropState.GROWING + ), + "Ready": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_state == CropState.READY + ), + "Idle": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, FarmerAgent) and a.crop_state == CropState.IDLE + ), + } + ) + + def _normalize_rainfall(self, rainfall: float | str | None) -> str: + if isinstance(rainfall, str): + rainfall_upper = rainfall.upper() + if rainfall_upper in {"LOW", "NORMAL", "HIGH"}: + return rainfall_upper + raise ValueError(f"Unsupported rainfall value: {rainfall}") + + if rainfall is None: + return "NORMAL" + if rainfall < 0.33: + return "LOW" + if rainfall > 0.66: + return "HIGH" + return "NORMAL" + + def step(self): + self.current_day += timedelta(days=1) + self.agents.shuffle_do("step") + self.datacollector.collect(self) + + if (self.current_day - self.start_date).days >= 120: + self.running = False + + +if __name__ == "__main__": + from examples.agriculture_model.app import model + + for _ in range(5): + model.step() diff --git a/examples/agriculture_model/readme.md b/examples/agriculture_model/readme.md new file mode 100644 index 000000000..0dc4ec040 --- /dev/null +++ b/examples/agriculture_model/readme.md @@ -0,0 +1,201 @@ +## Summary + +This model simulates how farmers make agricultural decisions under uncertainty such as rainfall variability, market prices, and social influence. + +Farmer agents are placed on a grid and manage their land over a growing season. Each farmer decides when to plant, apply fertilizer, and harvest crops. These decisions directly impact yield and profit. + +The model incorporates environmental factors such as rainfall conditions (low, normal, high) and economic factors such as crop market prices. Additionally, farmers can observe neighboring agents, introducing social influence into decision-making. + +This model is implemented using **Mesa-LLM**, meaning agents are capable of reasoning and tool usage rather than relying purely on fixed rules. Farmers can dynamically decide actions like planting or waiting based on context. + +--- + +## Technical Details + +The **Agriculture Decision Model** simulates farming behavior using a population of **Farmer agents**. + +Each **FarmerAgent** is characterized by: + +- `land_size` +- `wealth` +- `education_level` +- `crop_type` (wheat, rice, maize) +- `fertilizer` level +- `crop_state` +- `yield_output` +- `profit` + +--- + +### Crop Lifecycle + +Each farmer follows a simplified crop lifecycle: +``` bash REST → PLANTED → READY → HARVESTED → REST +``` + +--- + +### Yield Function + +Crop yield depends on rainfall and fertilizer usage: +``` bash +yield = base_yield × rainfall_factor × fertilizer_factor + +Where: + +- `rainfall_factor` depends on environmental condition (LOW / NORMAL / HIGH) +- `fertilizer_factor` increases yield when fertilizer is applied + +``` + +### Profit Calculation +``` bash profit = yield × market_price + +Where: + +- `market_price` depends on crop type + +``` + +### Environment + +- The model uses a **MultiGrid** environment +- Time progresses in **daily steps** +- Each simulation runs for ~120 days (one farming season) + +--- + +### Farmer Behavior + +Each Farmer agent: + +1. Observes: + - Rainfall condition + - Current crop state + - Neighbor behavior + +2. Decides: + - Plant crop + - Apply fertilizer + - Harvest + - Wait + +3. Executes actions using tools + +--- + +### Tools + +Farmer agents use tool-based actions: + +- `plant_crop` → plants crop and sets harvest date +- `apply_fertilizer` → increases yield potential +- `harvest_crop` → harvests when ready + +--- + +### LLM-Powered Agents + +Farmers are implemented as **LLM agents**, meaning: + +- Decisions are generated via a reasoning module +- Agents use: + - Internal state (wealth, crop, etc.) + - Observations (environment + neighbors) + - Available tools + +This allows: + +- Context-aware decision making +- Adaptive strategies +- Non-deterministic behavior + +--- + +## How to Run + +If you have cloned the repo into your local machine, ensure you run the following command from the root of the library: +``` bash pip install -e . +``` + +Then, set up your LLM API key. + +### Setup Steps + +1. Install dotenv if not already installed: +pip install python-dotenv +Copy code + +2. Create a `.env` file in the root directory + +3. Add your API key: + +For OpenAI: +``` python OPENAI_API_KEY=your-api-key +``` + + +For Gemini: +``` python GEMINI_API_KEY=your-api-key +``` + +4. In `app.py`, set: +``` python +- `llm_model = "openai/gpt-4o-mini"` (or your model) +``` + +--- + +### Run the Model +``` python +solara run app.py +``` + + +Open in browser: +``` bash http://localhost:8765⁠� +``` +--- + +## Files + +- `model.py` → Core simulation logic +- `agent.py` → Farmer agent definition +- `tools.py` → Actions available to agents +- `app.py` → Visualization and UI + +--- + +## Metrics + +The model tracks: + +- **Total Yield** +- **Average Profit** +- **Crop Distribution** + +--- + +## Further Reading + +This model is inspired by: + +> [Agent-Based Modeling in Agricultural Productivity in Rural Area of Bahir Dar](https://www.mdpi.com/2571-9394/4/1/20) + +Related work on Agent-Based Modeling in agriculture: + +- Agent-based crop decision models +- Climate adaptation simulations +- Socio-environmental systems + +--- + +## Notes + +- This model simplifies crop growth dynamics +- Focus is on **decision-making behavior**, not full biological simulation +- Can be extended with: + - Climate models + - Policy interventions + - Multi-season dynamics + diff --git a/examples/agriculture_model/tools.py b/examples/agriculture_model/tools.py new file mode 100644 index 000000000..0feca8610 --- /dev/null +++ b/examples/agriculture_model/tools.py @@ -0,0 +1,88 @@ +from datetime import timedelta + +from examples.agriculture_model.agent import FARMER_TOOL_MANAGER, CropState +from mesa_llm.tools.tool_decorator import tool + + +@tool(tool_manager=FARMER_TOOL_MANAGER) +def plant_crop(agent, days_to_harvest: int = 60): + """ + Plant a crop in the field. + + Args: + days_to_harvest: Number of days required for harvest. + + Returns: + A status message. + """ + try: + days_to_harvest = int(days_to_harvest) + except (TypeError, ValueError) as exc: + raise ValueError( + f"days_to_harvest must be an integer, got {days_to_harvest!r}" + ) from exc + if days_to_harvest <= 0: + raise ValueError("days_to_harvest must be greater than 0") + if agent.crop_state != CropState.IDLE: + return f"Cannot plant while crop is {agent.crop_state.value}" + + agent.crop_state = CropState.PLANTED + agent.plant_date = agent.model.current_day + agent.harvest_date = agent.model.current_day + timedelta(days=days_to_harvest) + agent.fertilizer = 0.0 + return "Crop planted" + + +@tool(tool_manager=FARMER_TOOL_MANAGER) +def apply_fertilizer(agent, level: float = 0.5): + """ + Apply fertilizer to the crop. + + Args: + level: Fertilizer intensity from 0 to 1. + + Returns: + A status message. + """ + try: + level = float(level) + except (TypeError, ValueError) as exc: + raise ValueError(f"level must be numeric, got {level!r}") from exc + if level < 0: + raise ValueError("level must be non-negative") + if agent.crop_state in [CropState.IDLE, CropState.READY]: + return "Cannot apply fertilizer at this stage" + + agent.fertilizer += level + if agent.crop_state == CropState.PLANTED: + agent.crop_state = CropState.GROWING + return f"Fertilizer applied: {level}" + + +@tool(tool_manager=FARMER_TOOL_MANAGER) +def harvest_crop(agent): + """ + Harvest the crop if ready. + + Args: + agent: Provided automatically. + + Returns: + A harvest status message. + """ + if ( + agent.harvest_date is not None + and agent.crop_state in {CropState.PLANTED, CropState.GROWING} + and agent.model.current_day >= agent.harvest_date + ): + agent.crop_state = CropState.READY + + if agent.crop_state != CropState.READY: + return "Not ready" + + agent.compute_yield() + agent.crop_state = CropState.IDLE + agent.plant_date = None + agent.harvest_date = None + agent.fertilizer = 0.0 + return "Harvested"