Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 33 additions & 32 deletions abses/utils/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,41 +250,42 @@ def _load_hydra_cfg(self, path: PathLike) -> None:
self.agent_reporter = reporters.get("agents", {})
self.final_reporter = reporters.get("final", {})

def read_data(self) -> None:
"""Read data from CSV files or datacollector output.

This method attempts to find and load data files in the following order:
1. Common CSV filenames (cities.csv, 1_cities.csv, etc.)
2. Datacollector output files if available
3. User-specified files

Raises:
FileNotFoundError: If no data file is found.
def read_data(self, suffix: str = "csv") -> pd.DataFrame:
"""Read and merge result csv files under the experiment folder.

This method will:
- First, look for all files matching ``*_cities.csv`` (e.g. ``1_cities.csv``,
``2_cities.csv`` ...) under ``self.path``.
- If found, read them all and vertically concatenate them into a single
dataframe.
- If none are found, fall back to reading a single ``cities.csv`` file.
"""
# Try common CSV filenames
common_names = ["cities.csv", "1_cities.csv", "data.csv", "results.csv"]
for name in common_names:
csv_path = self.path / name
if csv_path.is_file():
self.data = self.read_csv(csv_path)
logger.info(f"Loaded data from {csv_path}.")
return
# Prefer numbered runs like 1_cities.csv, 2_cities.csv, ...
csv_files = sorted(self.path.glob(f"*.{suffix}"))

# Try to find any CSV file in the directory
csv_files = list(self.path.glob("*.csv"))
if csv_files:
# Use the first CSV file found
self.data = self.read_csv(csv_files[0])
logger.info(f"Loaded data from {csv_files[0]}.")
return

# If no CSV found, try to load from datacollector output
# This would require the datacollector to have saved its output
# For now, we'll raise an error
raise FileNotFoundError(
f"No data file found in {self.path}. "
f"Expected CSV files or datacollector output."
)
data_frames = []
for csv_file in csv_files:
try:
df = self.read_csv(path=csv_file)
data_frames.append(df)
except FileNotFoundError:
logger.warning(f"Skip missing file: {csv_file}")
if not data_frames:
raise FileNotFoundError(
f"No valid *.{suffix} files found under {self.path}."
)
self.data = pd.concat(data_frames, ignore_index=True)
logger.info(
"Loaded and merged result files: "
f"{[f.name for f in csv_files]} from {self.path}."
)
return self.data
else:
# Backward compatibility: fall back to a single cities.csv
logger.warning(f"No valid *.{suffix} files found under {self.path}.")
self.data = pd.DataFrame()
return self.data

def read_csv(self, path: PathLike) -> pd.DataFrame:
"""Read a CSV file into a DataFrame.
Expand Down
6 changes: 3 additions & 3 deletions abses/utils/datacollector.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
if TYPE_CHECKING:
from abses.agents.actor import Actor
from abses.agents.sequences import ActorsList
from abses.main import MainModel
from abses.time import TimeDriver
from abses.core.model import MainModel
from abses.core.time_driver import TimeDriver

from abses.utils.tracker import TrackerProtocol

Expand Down Expand Up @@ -192,7 +192,7 @@ def _record_a_breed_of_agents(
result = {
"AgentID": agents.array("unique_id"),
"Step": np.repeat(time.tick, len(agents)),
"Time": np.repeat(time.dt, len(agents)),
"Time": np.repeat(str(time.dt), len(agents)),
}
for name, reporter in self.agent_reporters[breed].items():
result[name] = agents.apply(reporter)
Expand Down
16 changes: 14 additions & 2 deletions abses/utils/tracker/aim_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

from abses.utils.tracker import TrackerProtocol

try:
from omegaconf import DictConfig, OmegaConf
except ImportError:
DictConfig = None
OmegaConf = None

try:
from aim import Run
except ImportError:
Expand Down Expand Up @@ -140,12 +146,18 @@ def log_final_metrics(
if numeric_metrics:
self.log_metrics(numeric_metrics, step=step)

def log_params(self, params: Dict[str, Any]) -> None:
def log_params(self, params: Dict[str, Any] | DictConfig) -> None:
"""Log hyperparameters to Aim.

Args:
params: Dictionary of parameter names to values.
params: Dictionary of parameter names to values, or DictConfig.
"""
# If params is DictConfig, use Aim's built-in OmegaConf integration
if DictConfig is not None and isinstance(params, DictConfig):
self._run["config"] = OmegaConf.to_container(params, resolve=True)
return

# Otherwise, handle as regular dict
for key, value in params.items():
# Aim supports various types for parameters
if isinstance(value, (int, float, str, bool)):
Expand Down
18 changes: 13 additions & 5 deletions abses/utils/tracker/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,20 @@ def start_tracker_run(

log_params = cfg_dict.get("log_params", True)
if log_params and hasattr(tracker, "log_params"):
if isinstance(model_params, DictConfig):
params_dict = OmegaConf.to_container(model_params, resolve=True)
# For AimTracker, if model_params is DictConfig, pass it directly
# to use Aim's built-in OmegaConf integration
if type(tracker).__name__ == "AimTracker" and isinstance(
model_params, DictConfig
):
tracker.log_params(model_params)
else:
params_dict = dict(model_params)
if isinstance(params_dict, dict):
tracker.log_params(params_dict)
# For other trackers or plain dict, convert to dict first
if isinstance(model_params, DictConfig):
params_dict = OmegaConf.to_container(model_params, resolve=True)
else:
params_dict = dict(model_params)
if isinstance(params_dict, dict):
tracker.log_params(params_dict)


def create_tracker(
Expand Down
84 changes: 72 additions & 12 deletions abses/viz/solara.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@
from mesa.visualization.utils import update_counter
from xarray import DataArray

from abses.main import MainModel
from abses.patch import PatchModule
try:
from mesa.visualization.components import AgentPortrayalStyle
except ImportError:
AgentPortrayalStyle = None

from abses import MainModel, PatchModule
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def draw_property_layers(
Expand Down Expand Up @@ -106,12 +110,14 @@ def collect_agent_data(
Args:
space: The space containing the Agents.
agent_portrayal: A callable that is called with the agent and returns a dict
or AgentPortrayalStyle object (Mesa 3.3+)
color: default color
size: default size
marker: default marker
zorder: default zorder

agent_portrayal should return a dict, limited to size (size of marker), color (color of marker), zorder (z-order),
agent_portrayal should return a dict or AgentPortrayalStyle object, limited to
size (size of marker), color (color of marker), zorder (z-order),
marker (marker style), alpha, linewidths, and edgecolors

"""
Expand All @@ -126,7 +132,43 @@ def collect_agent_data(
}

for agent in space.agents:
portray = agent_portrayal(agent)
portray_result = agent_portrayal(agent)

# Convert AgentPortrayalStyle to dict if needed (Mesa 3.3+)
if AgentPortrayalStyle is not None and isinstance(
portray_result, AgentPortrayalStyle
):
# AgentPortrayalStyle objects can be converted using vars() or direct attribute access
try:
# Try vars() first (works for most Python objects with __dict__)
portray = vars(portray_result).copy()
except (TypeError, AttributeError):
# Fallback: build dict from direct attribute access
portray = {}
for attr in [
"size",
"color",
"marker",
"zorder",
"alpha",
"edgecolors",
"linewidths",
]:
if hasattr(portray_result, attr):
value = getattr(portray_result, attr)
if value is not None:
portray[attr] = value
elif isinstance(portray_result, dict):
portray = portray_result.copy()
else:
# Fallback: try to convert to dict
try:
portray = (
vars(portray_result) if hasattr(portray_result, "__dict__") else {}
)
except (TypeError, AttributeError):
portray = {}

arguments["s"].append(portray.pop("size", size))
arguments["c"].append(portray.pop("color", color))
arguments["marker"].append(portray.pop("marker", marker))
Expand All @@ -145,7 +187,21 @@ def collect_agent_data(
)
# ensure loc is always a shape of (n, 2) array, even if n=0
result = {k: np.asarray(v) for k, v in arguments.items()}
result["loc"] = space.agents.array("indices")
indices_array = space.agents.array("indices")
# Convert to (n, 2) shape
if len(indices_array) == 0:
result["loc"] = np.empty((0, 2), dtype=int)
else:
# Convert list of tuples/arrays to 2D array
result["loc"] = np.array(
[
list(idx) if isinstance(idx, (tuple, list)) else idx
for idx in indices_array
]
)
# Ensure it's 2D
if result["loc"].ndim == 1:
result["loc"] = result["loc"].reshape(-1, 2)
Comment on lines +190 to +204
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where 'indices' is defined or assigned
echo "=== Search for indices property/attribute definition ==="
rg -n "indices\s*=" --type=py | head -20

echo -e "\n=== Search for array method in agents ==="
rg -n "def array\(" --type=py -A 5

echo -e "\n=== Search for indices in agents module ==="
rg -n "indices" --type=py -g '**/agents/**' -C 2 | head -40

Repository: SongshGeoLab/ABSESpy

Length of output: 3884


🏁 Script executed:

#!/bin/bash
# Find the ActorsList or agents class definition
echo "=== Finding ActorsList class ==="
find . -type f -name "*.py" | xargs grep -l "class ActorsList\|class Agents" | head -10

# Check what array() method returns
echo -e "\n=== Searching for array method ==="
rg -n "def array" --type=py -A 10 | grep -A 10 "def array"

Repository: SongshGeoLab/ABSESpy

Length of output: 1982


🏁 Script executed:

#!/bin/bash
# Get more context about the solara.py file and indices usage
echo "=== Full context around lines 190-204 in solara.py ==="
cat -n abses/viz/solara.py | sed -n '180,210p'

echo -e "\n=== Search for 'indices' in solara.py ==="
rg -n "indices" abses/viz/solara.py -B 3 -A 3

Repository: SongshGeoLab/ABSESpy

Length of output: 2061


🏁 Script executed:

#!/bin/bash
# Check if there are any filters/guards before calling array("indices")
echo "=== Context in solara.py before indices_array usage ==="
cat -n abses/viz/solara.py | sed -n '160,210p'

echo -e "\n=== Check if actors are filtered for spatial position ==="
rg -n "on_earth|at is not None" abses/viz/solara.py -B 5 -A 5

Repository: SongshGeoLab/ABSESpy

Length of output: 2429


🏁 Script executed:

#!/bin/bash
# Check what happens with None values in the actual array conversion
echo "=== Check for None handling in indices ==="
rg -n "indices.*None\|None.*indices" --type=py -B 2 -A 2

# See if there are tests for the solara visualization
echo -e "\n=== Check solara.py tests ==="
fd "test.*solara\|solara.*test" --type=py

Repository: SongshGeoLab/ABSESpy

Length of output: 324


🏁 Script executed:

#!/bin/bash
# Check what function this code is in and how it's called
echo "=== Function containing this code ==="
cat -n abses/viz/solara.py | sed -n '120,195p'

echo -e "\n=== Check for filtering of spatial actors ==="
rg -n "on_earth\|\.at\s*is\s*not\s*None" abses/viz/solara.py -B 3 -A 3

Repository: SongshGeoLab/ABSESpy

Length of output: 3598


🏁 Script executed:

#!/bin/bash
# Check the broader context and how agents are selected
echo "=== Check entire function ==="
sed -n '100,210p' abses/viz/solara.py | cat -n

# Check what happens when None is in the array
echo -e "\n=== Test what np.array does with None and tuples ==="
python3 << 'EOF'
import numpy as np

# Simulate what space.agents.array("indices") returns with mixed None/tuples
mixed_indices = [None, (0, 0), (1, 1)]
arr = np.array(mixed_indices)
print(f"Array from mixed None/tuples: {arr}")
print(f"Array dtype: {arr.dtype}")
print(f"Array shape: {arr.shape}")
print(f"Array ndim: {arr.ndim}")

# What happens with list comprehension in the code
result = np.array([
    list(idx) if isinstance(idx, (tuple, list)) else idx
    for idx in arr
])
print(f"\nAfter list comprehension: {result}")
print(f"Result dtype: {result.dtype}")
print(f"Result shape: {result.shape}")
print(f"Result ndim: {result.ndim}")

# Can it be reshaped?
try:
    reshaped = result.reshape(-1, 2)
    print(f"Reshaped successfully: {reshaped.shape}")
except Exception as e:
    print(f"Reshape failed: {type(e).__name__}: {e}")
EOF

Repository: SongshGeoLab/ABSESpy

Length of output: 5007


🏁 Script executed:

#!/bin/bash
# Test with pip installed numpy
pip install -q numpy 2>/dev/null

python3 << 'EOF'
import numpy as np

print("=== Simulating what space.agents.array('indices') returns ===")
print("Case 1: All agents have spatial positions")
indices_all_spatial = [(0, 0), (1, 1), (2, 2)]
arr1 = np.array(indices_all_spatial)
print(f"Indices: {indices_all_spatial}")
print(f"Array: {arr1}, shape: {arr1.shape}, dtype: {arr1.dtype}")

print("\nCase 2: Some agents lack spatial positions (None values)")
indices_mixed = [None, (0, 0), (1, 1), None, (2, 2)]
arr2 = np.array(indices_mixed)
print(f"Indices: {indices_mixed}")
print(f"Array: {arr2}, shape: {arr2.shape}, dtype: {arr2.dtype}, ndim: {arr2.ndim}")

print("\nCase 3: Processing mixed array with list comprehension (as in code)")
result = np.array([
    list(idx) if isinstance(idx, (tuple, list)) else idx
    for idx in arr2
])
print(f"After list comp: {result}, shape: {result.shape}, dtype: {result.dtype}, ndim: {result.ndim}")

print("\nCase 4: Attempting reshape(-1, 2) on mixed array")
try:
    reshaped = result.reshape(-1, 2)
    print(f"Reshaped successfully: {reshaped}, shape: {reshaped.shape}")
except Exception as e:
    print(f"Reshape FAILED: {type(e).__name__}: {e}")

print("\nCase 5: All tuples (no None)")
indices_tuples = [(0, 0), (1, 1), (2, 2)]
result_tuples = np.array([
    list(idx) if isinstance(idx, (tuple, list)) else idx
    for idx in indices_tuples
])
print(f"Result: {result_tuples}, shape: {result_tuples.shape}, ndim: {result_tuples.ndim}")
EOF

Repository: SongshGeoLab/ABSESpy

Length of output: 579


🏁 Script executed:

#!/bin/bash
# Check if there's filtering logic elsewhere or if this is truly an issue
echo "=== Check if actors without spatial positions can exist ==="
rg -n "on_earth\|\.at\s*=\s*None\|indices.*None" --type python -B 2 -A 2 | head -40

echo -e "\n=== Check if space.agents includes non-spatial actors ==="
rg -n "space.agents\|agent in.*agents" abses/viz/solara.py -B 1 -A 1

Repository: SongshGeoLab/ABSESpy

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Test numpy with correct exception handling
pip install -q numpy 2>/dev/null

python3 << 'EOF'
import numpy as np

print("=== Understanding the actual behavior ===")

print("\nCase 1: All spatial positions (tuples)")
indices_all = [(0, 0), (1, 1), (2, 2)]
arr1 = np.array(indices_all)
print(f"Input: {indices_all}")
print(f"np.array result: shape {arr1.shape}, dtype {arr1.dtype}")
print(f"ndim: {arr1.ndim}")

print("\nCase 2: Mixed None and tuples - NumPy cannot create uniform array")
indices_mixed = [None, (0, 0), (1, 1)]
try:
    arr2 = np.array(indices_mixed)
except ValueError as e:
    print(f"np.array() fails: {e}")
    # Create object array instead
    arr2 = np.array(indices_mixed, dtype=object)
    print(f"np.array(dtype=object) result: shape {arr2.shape}, dtype {arr2.dtype}")
    print(f"Content: {arr2}")
    
    print("\n  After list comprehension:")
    result = np.array([
        list(idx) if isinstance(idx, (tuple, list)) else idx
        for idx in arr2
    ], dtype=object)
    print(f"  Result: shape {result.shape}, ndim {result.ndim}, dtype {result.dtype}")
    print(f"  Content: {result}")
    
    print("\n  Attempting reshape(-1, 2):")
    try:
        reshaped = result.reshape(-1, 2)
        print(f"  Success: {reshaped}")
    except Exception as e2:
        print(f"  Failed: {type(e2).__name__}: {e2}")

print("\nCase 3: How space.agents.array() works")
print("It calls np.array(self.get(attr)) on a list of all agent indices")
print("If some agents have None (not positioned), this would create dtype=object array")
EOF

Repository: SongshGeoLab/ABSESpy

Length of output: 957


🏁 Script executed:

#!/bin/bash
# Check filtering and how the function is used
echo "=== Check how agents are filtered before collecting data ==="
rg -n "collect_agent_data" abses/viz/solara.py -B 5 -A 5

echo -e "\n=== Check if there's filtering by on_earth or spatial position ==="
rg -n "on_earth" abses/viz/ -B 2 -A 2

Repository: SongshGeoLab/ABSESpy

Length of output: 842


🏁 Script executed:

#!/bin/bash
# Check what agents are included in space.agents
echo "=== Check if space.agents is all agents or only spatial ones ==="
rg -n "def agents" abses/space/ --type py -A 10 | head -50

echo -e "\n=== Check space module structure ==="
ls -la abses/space/

Repository: SongshGeoLab/ABSESpy

Length of output: 1841


🏁 Script executed:

#!/bin/bash
# Check if there are any agents that are in model.agents but not in space.agents
echo "=== Check model.agents vs space.agents ==="
rg -n "model.agents\|space.agents" abses/viz/solara.py -B 2 -A 2

echo -e "\n=== Check if there are tests showing how space.agents.array works ==="
fd "test" abses/viz/ --type f -name "*.py"

echo -e "\n=== Verify that all agents in the loop have valid indices ==="
cat -n abses/viz/solara.py | sed -n '134,140p'

Repository: SongshGeoLab/ABSESpy

Length of output: 803


🏁 Script executed:

#!/bin/bash
# Final check: can indices be None in space.agents?
echo "=== Can an agent in space.agents have None indices? ==="
rg -n "at\s*=\s*None" abses/agents/ -B 2 -A 2

echo -e "\n=== How is an agent added to a cell/space? ==="
rg -n "def move\|def place" abses/agents/actor.py -A 10 | head -40

Repository: SongshGeoLab/ABSESpy

Length of output: 167


🏁 Script executed:

#!/bin/bash
# Final verification: space.agents property
echo "=== Full context of space.agents property ==="
sed -n '400,410p' abses/space/patch.py | cat -n

echo -e "\n=== What is cells_lst? ==="
rg -n "cells_lst" abses/space/patch.py -B 2 -A 2 | head -30

echo -e "\n=== Confirm: are there any agents outside of cells? ==="
rg -n "model.agents\|AgentList" abses/space/patch.py -B 2 -A 2 | head -40

Repository: SongshGeoLab/ABSESpy

Length of output: 1785


🏁 Script executed:

#!/bin/bash
# Check if there's any way agents can be in space but not spatial
echo "=== Actor indices property ==="
sed -n '309,315p' abses/agents/actor.py | cat -n

echo -e "\n=== Where are agents without spatial position kept? ==="
rg -n "model.agents" abses/core/ --type py -B 2 -A 2 | head -30

Repository: SongshGeoLab/ABSESpy

Length of output: 1584


🏁 Script executed:

#!/bin/bash
# Verify the guarantee: space.agents only returns agents in cells
echo "=== Confirming space.agents only includes spatial agents ==="
cat -n abses/space/patch.py | sed -n '400,410p'

echo -e "\n=== What happens when agent is added to cell? ==="
rg -n "self.at\s*=" abses/agents/ -B 2 -A 2 | head -40

echo -e "\n=== Conclusion: what does space.agents.array('indices') return? ==="
python3 << 'EOF'
# Simulating: all agents in space.agents are in cells
# All agents in cells have valid tuple indices
indices_data = [(0, 0), (1, 1), (2, 2), (0, 1)]

import sys
sys.path.insert(0, '/tmp')

# This is what np.array() does with all tuples
try:
    import numpy as np
    result = np.array(indices_data)
    print(f"space.agents.array('indices') returns:")
    print(f"  Shape: {result.shape}")
    print(f"  Dtype: {result.dtype}")
    print(f"  ndim: {result.ndim}")
    print(f"  Content: {result}")
    print(f"\nConclusion: Already a (n, 2) array - no conversion needed!")
except ImportError:
    print("NumPy not available, but the logic is clear")
EOF

Repository: SongshGeoLab/ABSESpy

Length of output: 883


Simplify indices conversion—it's already guaranteed to be consistent tuples.

The space.agents property only includes agents that are in cells (see patch.py:400-405), and all agents in cells always have valid indices as tuples from their cell's position. The defensive logic handling None values, mixed types, and 1D reshaping is unnecessary.

The code can be reduced to:

indices_array = space.agents.array("indices")
result["loc"] = np.empty((0, 2), dtype=int) if len(indices_array) == 0 else np.array(indices_array)
🤖 Prompt for AI Agents
In @abses/viz/solara.py around lines 190 - 204, The conversion of agent indices
is over-defensive: simplify the logic in the block using
space.agents.array("indices") and result["loc"]; remove the None/mixed-type
handling and ndim reshaping and instead assign result["loc"] to an empty (0,2)
int array when indices_array is empty, otherwise set result["loc"] directly to
np.array(indices_array), since indices are guaranteed tuples.

return result


Expand All @@ -168,8 +224,9 @@ def draw_orthogonal_grid(
Returns:
Returns the Axes object with the plot drawn onto it.

``agent_portrayal`` is called with an agent and should return a dict. Valid fields in this dict are "color",
"size", "marker", and "zorder". Other field are ignored and will result in a user warning.
``agent_portrayal`` is called with an agent and should return a dict or AgentPortrayalStyle
object (Mesa 3.3+). Valid fields are "color", "size", "marker", "zorder", "alpha",
"linewidths", and "edgecolors". Other fields are ignored and will result in a user warning.

"""
if ax is None:
Expand Down Expand Up @@ -214,6 +271,11 @@ def SpaceMatplotlib(
fig = Figure()
ax = fig.add_subplot()

# Draw property layers first (background)
if propertylayer_portrayal:
draw_property_layers(space, propertylayer_portrayal, ax=ax)

# Draw agents on top
draw_orthogonal_grid(
space,
agent_portrayal,
Expand All @@ -228,9 +290,6 @@ def SpaceMatplotlib(
fig, format="png", bbox_inches="tight", dependencies=dependencies
)

if propertylayer_portrayal:
draw_property_layers(space, propertylayer_portrayal, ax=ax)


def make_mpl_space_component(
agent_portrayal: Callable | None = None,
Expand All @@ -247,8 +306,9 @@ def make_mpl_space_component(
space_drawing_kwargs : additional keyword arguments to be passed on to the underlying space drawer function. See
the functions for drawing the various spaces for further details.

``agent_portrayal`` is called with an agent and should return a dict. Valid fields in this dict are "color",
"size", "marker", "zorder", alpha, linewidths, and edgecolors. Other field are ignored and will result in a user warning.
``agent_portrayal`` is called with an agent and should return a dict or AgentPortrayalStyle
object (Mesa 3.3+). Valid fields are "color", "size", "marker", "zorder", "alpha",
"linewidths", and "edgecolors". Other fields are ignored and will result in a user warning.

Returns:
function: A function that creates a SpaceMatplotlib component
Expand Down
1 change: 1 addition & 0 deletions docs/api/analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ date: 2024-12-20




Loading
Loading