modelly-abm 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modelly/__init__.py +37 -0
- modelly/_native/__init__.py +0 -0
- modelly/agent.py +282 -0
- modelly/agentset/__init__.py +36 -0
- modelly/agentset/_utils.py +132 -0
- modelly/agentset/abstract.py +352 -0
- modelly/agentset/groupby.py +142 -0
- modelly/agentset/selection.py +163 -0
- modelly/agentset/strong.py +160 -0
- modelly/agentset/weak.py +274 -0
- modelly/batch.py +140 -0
- modelly/cohorts.py +198 -0
- modelly/column_access.py +81 -0
- modelly/columns.py +301 -0
- modelly/datacollection.py +394 -0
- modelly/discrete_space/__init__.py +55 -0
- modelly/discrete_space/base_grid.py +325 -0
- modelly/discrete_space/cell.py +317 -0
- modelly/discrete_space/cell_agent.py +226 -0
- modelly/discrete_space/cell_collection.py +189 -0
- modelly/discrete_space/cell_index.py +138 -0
- modelly/discrete_space/discrete_space.py +302 -0
- modelly/discrete_space/grid.py +230 -0
- modelly/discrete_space/network.py +181 -0
- modelly/discrete_space/occupancy.py +113 -0
- modelly/discrete_space/property_layer.py +146 -0
- modelly/discrete_space/voronoi.py +315 -0
- modelly/examples/README.md +54 -0
- modelly/examples/__init__.py +25 -0
- modelly/examples/advanced/__init__.py +0 -0
- modelly/examples/advanced/alliance_formation/Readme.md +45 -0
- modelly/examples/advanced/alliance_formation/__init__.py +0 -0
- modelly/examples/advanced/alliance_formation/agents.py +20 -0
- modelly/examples/advanced/alliance_formation/app.py +83 -0
- modelly/examples/advanced/alliance_formation/model.py +201 -0
- modelly/examples/advanced/epstein_civil_violence/Epstein Civil Violence.ipynb +116 -0
- modelly/examples/advanced/epstein_civil_violence/Readme.md +38 -0
- modelly/examples/advanced/epstein_civil_violence/__init__.py +0 -0
- modelly/examples/advanced/epstein_civil_violence/agents.py +140 -0
- modelly/examples/advanced/epstein_civil_violence/app.py +92 -0
- modelly/examples/advanced/epstein_civil_violence/model.py +125 -0
- modelly/examples/advanced/pd_grid/Readme.md +45 -0
- modelly/examples/advanced/pd_grid/__init__.py +0 -0
- modelly/examples/advanced/pd_grid/agents.py +48 -0
- modelly/examples/advanced/pd_grid/analysis.ipynb +183 -0
- modelly/examples/advanced/pd_grid/app.py +62 -0
- modelly/examples/advanced/pd_grid/model.py +89 -0
- modelly/examples/advanced/sugarscape_g1mt/Readme.md +71 -0
- modelly/examples/advanced/sugarscape_g1mt/__init__.py +0 -0
- modelly/examples/advanced/sugarscape_g1mt/agents.py +301 -0
- modelly/examples/advanced/sugarscape_g1mt/app.py +82 -0
- modelly/examples/advanced/sugarscape_g1mt/model.py +134 -0
- modelly/examples/advanced/sugarscape_g1mt/sugar-map.txt +50 -0
- modelly/examples/advanced/wolf_sheep/Readme.md +46 -0
- modelly/examples/advanced/wolf_sheep/__init__.py +0 -0
- modelly/examples/advanced/wolf_sheep/agents.py +154 -0
- modelly/examples/advanced/wolf_sheep/app.py +92 -0
- modelly/examples/advanced/wolf_sheep/model.py +146 -0
- modelly/examples/basic/__init__.py +0 -0
- modelly/examples/basic/boid_flockers/Readme.md +31 -0
- modelly/examples/basic/boid_flockers/__init__.py +0 -0
- modelly/examples/basic/boid_flockers/agents.py +93 -0
- modelly/examples/basic/boid_flockers/app.py +88 -0
- modelly/examples/basic/boid_flockers/model.py +123 -0
- modelly/examples/basic/boltzmann_wealth_model/Readme.md +63 -0
- modelly/examples/basic/boltzmann_wealth_model/__init__.py +0 -0
- modelly/examples/basic/boltzmann_wealth_model/agents.py +48 -0
- modelly/examples/basic/boltzmann_wealth_model/app.py +99 -0
- modelly/examples/basic/boltzmann_wealth_model/model.py +97 -0
- modelly/examples/basic/boltzmann_wealth_model/st_app.py +117 -0
- modelly/examples/basic/conways_game_of_life/Readme.md +38 -0
- modelly/examples/basic/conways_game_of_life/__init__.py +0 -0
- modelly/examples/basic/conways_game_of_life/agents.py +55 -0
- modelly/examples/basic/conways_game_of_life/app.py +73 -0
- modelly/examples/basic/conways_game_of_life/model.py +36 -0
- modelly/examples/basic/conways_game_of_life/st_app.py +73 -0
- modelly/examples/basic/schelling/Readme.md +37 -0
- modelly/examples/basic/schelling/__init__.py +0 -0
- modelly/examples/basic/schelling/agents.py +47 -0
- modelly/examples/basic/schelling/app.py +93 -0
- modelly/examples/basic/schelling/model.py +93 -0
- modelly/examples/basic/schelling/resources/blue_happy.png +0 -0
- modelly/examples/basic/schelling/resources/blue_unhappy.png +0 -0
- modelly/examples/basic/schelling/resources/orange_happy.png +0 -0
- modelly/examples/basic/schelling/resources/orange_unhappy.png +0 -0
- modelly/examples/basic/virus_on_network/Readme.md +44 -0
- modelly/examples/basic/virus_on_network/__init__.py +0 -0
- modelly/examples/basic/virus_on_network/agents.py +65 -0
- modelly/examples/basic/virus_on_network/app.py +140 -0
- modelly/examples/basic/virus_on_network/model.py +92 -0
- modelly/examples/experimental/__init__.py +0 -0
- modelly/examples/experimental/tram_model/Readme.md +67 -0
- modelly/examples/experimental/tram_model/__init__.py +0 -0
- modelly/examples/experimental/tram_model/agents.py +158 -0
- modelly/examples/experimental/tram_model/app.py +65 -0
- modelly/examples/experimental/tram_model/model.py +116 -0
- modelly/examples/experimental/tram_model/utils.py +223 -0
- modelly/exceptions.py +92 -0
- modelly/experimental/__init__.py +18 -0
- modelly/experimental/actions/__init__.py +9 -0
- modelly/experimental/actions/actions.py +721 -0
- modelly/experimental/continuous_space/__init__.py +19 -0
- modelly/experimental/continuous_space/continuous_space.py +341 -0
- modelly/experimental/continuous_space/continuous_space_agents.py +93 -0
- modelly/experimental/continuous_space/vectorised.py +64 -0
- modelly/experimental/data_collection/__init__.py +32 -0
- modelly/experimental/data_collection/basedatarecorder.py +303 -0
- modelly/experimental/data_collection/datarecorders.py +693 -0
- modelly/experimental/data_collection/dataset.py +628 -0
- modelly/experimental/meta_agents/__init__.py +9 -0
- modelly/experimental/meta_agents/backend.py +135 -0
- modelly/experimental/meta_agents/meta_agent.py +191 -0
- modelly/experimental/meta_agents/meta_agents_api.py +459 -0
- modelly/experimental/scenarios/__init__.py +27 -0
- modelly/experimental/scenarios/exceptions.py +210 -0
- modelly/experimental/scenarios/runner.py +280 -0
- modelly/experimental/scenarios/scenario.py +344 -0
- modelly/experimental/scenarios/store.py +257 -0
- modelly/experimental/signals/__init__.py +37 -0
- modelly/experimental/signals/batching.py +275 -0
- modelly/experimental/signals/core.py +775 -0
- modelly/experimental/signals/observable_collections.py +192 -0
- modelly/experimental/signals/signal_types.py +87 -0
- modelly/experimental/signals/signals_util.py +75 -0
- modelly/experimental/states/__init__.py +5 -0
- modelly/experimental/states/state.py +492 -0
- modelly/lint/__init__.py +82 -0
- modelly/lint/__main__.py +85 -0
- modelly/lint/checker.py +240 -0
- modelly/lint/rules.py +182 -0
- modelly/log.py +197 -0
- modelly/migrate.py +264 -0
- modelly/model.py +330 -0
- modelly/py.typed +0 -0
- modelly/reporters.py +244 -0
- modelly/rng.py +205 -0
- modelly/scheduling.py +155 -0
- modelly/time/__init__.py +15 -0
- modelly/time/events.py +600 -0
- modelly/visualization/__init__.py +29 -0
- modelly/visualization/backends/__init__.py +23 -0
- modelly/visualization/backends/abstract_renderer.py +120 -0
- modelly/visualization/backends/altair_backend.py +467 -0
- modelly/visualization/backends/matplotlib_backend.py +430 -0
- modelly/visualization/command_console.py +482 -0
- modelly/visualization/components/__init__.py +103 -0
- modelly/visualization/components/altair_components.py +529 -0
- modelly/visualization/components/matplotlib_components.py +177 -0
- modelly/visualization/components/portrayal_components.py +123 -0
- modelly/visualization/mpl_space_drawing.py +855 -0
- modelly/visualization/solara_viz.py +931 -0
- modelly/visualization/space_drawers.py +852 -0
- modelly/visualization/space_renderer.py +485 -0
- modelly/visualization/user_param.py +69 -0
- modelly/visualization/utils.py +9 -0
- modelly_abm-1.0.0.dist-info/METADATA +663 -0
- modelly_abm-1.0.0.dist-info/RECORD +160 -0
- modelly_abm-1.0.0.dist-info/WHEEL +4 -0
- modelly_abm-1.0.0.dist-info/licenses/LICENSE +201 -0
- modelly_abm-1.0.0.dist-info/licenses/NOTICE +30 -0
modelly/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Modelly Agent-Based Modeling Framework.
|
|
2
|
+
|
|
3
|
+
Core Objects: Model, and Agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import datetime
|
|
7
|
+
|
|
8
|
+
import modelly.discrete_space as discrete_space
|
|
9
|
+
import modelly.experimental as experimental
|
|
10
|
+
import modelly.time as time
|
|
11
|
+
from modelly.agent import Agent
|
|
12
|
+
from modelly.batch import run_batch, run_grid
|
|
13
|
+
from modelly.cohorts import CohortTable
|
|
14
|
+
from modelly.columns import Column
|
|
15
|
+
from modelly.datacollection import DataCollector
|
|
16
|
+
from modelly.model import Model
|
|
17
|
+
from modelly.rng import FastRandom
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Agent",
|
|
21
|
+
"CohortTable",
|
|
22
|
+
"Column",
|
|
23
|
+
"DataCollector",
|
|
24
|
+
"FastRandom",
|
|
25
|
+
"Model",
|
|
26
|
+
"discrete_space",
|
|
27
|
+
"experimental",
|
|
28
|
+
"run_batch",
|
|
29
|
+
"run_grid",
|
|
30
|
+
"time",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__title__ = "modelly"
|
|
34
|
+
__version__ = "1.0.0"
|
|
35
|
+
__license__ = "Apache 2.0"
|
|
36
|
+
_this_year = datetime.datetime.now(tz=datetime.UTC).date().year
|
|
37
|
+
__copyright__ = f"Copyright {_this_year} Modelly Team"
|
|
File without changes
|
modelly/agent.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Agent related classes.
|
|
2
|
+
|
|
3
|
+
Core Objects: Agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Postpone annotation evaluation to avoid NameError from forward references (PEP 563). Remove once Python 3.14+ is required.
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
from random import Random
|
|
11
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from modelly.model import Model
|
|
18
|
+
|
|
19
|
+
from modelly.agentset import AgentSet, _resolve_per_agent_values
|
|
20
|
+
from modelly.column_access import collect_column_specs, install_column_properties
|
|
21
|
+
from modelly.columns import Column
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Agent[M: Model]:
|
|
25
|
+
"""Base class for a model agent in Modelly.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
model (Model): A reference to the model instance.
|
|
29
|
+
unique_id (int): A unique identifier for this agent.
|
|
30
|
+
|
|
31
|
+
Notes:
|
|
32
|
+
Agents must be hashable to be used in an AgentSet.
|
|
33
|
+
In Python 3, defining `__eq__` without `__hash__` makes an object unhashable,
|
|
34
|
+
which will break AgentSet usage.
|
|
35
|
+
unique_id is unique relative to a model instance and starts from 1
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_datasets: ClassVar = set()
|
|
40
|
+
_column_specs: ClassVar[dict[str, Column]] = {}
|
|
41
|
+
_column_row: int = -1 # instance attribute once the agent has a column row
|
|
42
|
+
# True when no class after Agent in the MRO defines __init__, so the
|
|
43
|
+
# cooperative super().__init__() would only reach object.__init__.
|
|
44
|
+
_plain_init: ClassVar[bool] = True
|
|
45
|
+
# True when the class has columns or datasets to update on birth and death.
|
|
46
|
+
_extra_hooks: ClassVar[bool] = False
|
|
47
|
+
_repr_excluded_fields: ClassVar[set[str]] = {
|
|
48
|
+
"model",
|
|
49
|
+
"current_action",
|
|
50
|
+
"unique_id",
|
|
51
|
+
"_column_table",
|
|
52
|
+
"_column_row",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def __init_subclass__(cls, **kwargs):
|
|
56
|
+
"""Called when Agent is subclassed, giving each subclass its own dataset set."""
|
|
57
|
+
super().__init_subclass__(**kwargs)
|
|
58
|
+
# Each subclass gets its own dataset set
|
|
59
|
+
# we use strings on this to avoid memory leaks
|
|
60
|
+
# and ensure the retrieved dataset belongs to the same
|
|
61
|
+
# model instance as the agent
|
|
62
|
+
cls._datasets = set()
|
|
63
|
+
# Columns declared on this class or inherited, in MRO order; each one is
|
|
64
|
+
# replaced on the class by a fast property (see modelly.column_access).
|
|
65
|
+
specs = collect_column_specs(cls)
|
|
66
|
+
cls._column_specs = specs
|
|
67
|
+
install_column_properties(cls, specs)
|
|
68
|
+
after_agent = cls.__mro__[cls.__mro__.index(Agent) + 1 : -1]
|
|
69
|
+
cls._plain_init = not any("__init__" in vars(base) for base in after_agent)
|
|
70
|
+
cls._extra_hooks = bool(specs)
|
|
71
|
+
|
|
72
|
+
def __init__(self, model: M, *args, **kwargs) -> None:
|
|
73
|
+
"""Create a new agent.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
model (Model): The model instance in which the agent exists.
|
|
77
|
+
args: Passed on to super.
|
|
78
|
+
kwargs: Passed on to super.
|
|
79
|
+
|
|
80
|
+
Notes:
|
|
81
|
+
to make proper use of python's super, in each class remove the arguments and
|
|
82
|
+
keyword arguments you need and pass on the rest to super
|
|
83
|
+
"""
|
|
84
|
+
if args or kwargs:
|
|
85
|
+
super().__init__(*args, **kwargs)
|
|
86
|
+
elif not self._plain_init:
|
|
87
|
+
super().__init__() # a cooperative base class needs the call
|
|
88
|
+
|
|
89
|
+
self.model: M = model
|
|
90
|
+
if model._quiet:
|
|
91
|
+
# Model.register_agent written out for the common case (default
|
|
92
|
+
# registry, nobody listening): a birth is the hottest path in
|
|
93
|
+
# lifecycle-heavy models. Kept in sync with it (tested).
|
|
94
|
+
model._all_agents[self] = None
|
|
95
|
+
self.unique_id = model.agent_id_counter
|
|
96
|
+
model.agent_id_counter += 1
|
|
97
|
+
try:
|
|
98
|
+
model._agents_by_type[type(self)][self] = None
|
|
99
|
+
except KeyError:
|
|
100
|
+
model._register_new_type(self)
|
|
101
|
+
else:
|
|
102
|
+
self.unique_id = None
|
|
103
|
+
model.register_agent(self)
|
|
104
|
+
if self._extra_hooks:
|
|
105
|
+
if self._column_specs:
|
|
106
|
+
model.columns.register(self)
|
|
107
|
+
for dataset in self._datasets:
|
|
108
|
+
model.data_registry[dataset].add_agent(self)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def column_specs(cls) -> dict[str, Column]:
|
|
112
|
+
"""The ``Column`` declarations of this class (name to column), in table order.
|
|
113
|
+
|
|
114
|
+
On the class, a column attribute is the fast property that reads the
|
|
115
|
+
agent's row; this returns the declarations themselves (dtype, default).
|
|
116
|
+
"""
|
|
117
|
+
return dict(cls._column_specs)
|
|
118
|
+
|
|
119
|
+
def remove(self) -> None:
|
|
120
|
+
"""Remove and delete the agent from the model."""
|
|
121
|
+
# try/except rather than contextlib.suppress: the context manager costs
|
|
122
|
+
# about 300 ns per call and this runs on every agent death.
|
|
123
|
+
model = self.model
|
|
124
|
+
try:
|
|
125
|
+
if model._quiet:
|
|
126
|
+
# Model.deregister_agent written out; see __init__.
|
|
127
|
+
del model._agents_by_type[type(self)][self]
|
|
128
|
+
del model._all_agents[self]
|
|
129
|
+
else:
|
|
130
|
+
model.deregister_agent(self)
|
|
131
|
+
except KeyError:
|
|
132
|
+
pass # already removed
|
|
133
|
+
if self._extra_hooks:
|
|
134
|
+
if self._column_specs and self._column_row >= 0:
|
|
135
|
+
model.columns.unregister(self)
|
|
136
|
+
# ensures models are also removed from datasets
|
|
137
|
+
for dataset in self._datasets:
|
|
138
|
+
model.data_registry[dataset].remove_agent(self)
|
|
139
|
+
|
|
140
|
+
def step(self) -> None:
|
|
141
|
+
"""A single step of the agent."""
|
|
142
|
+
|
|
143
|
+
def advance(self) -> None: # noqa: D102
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def create_agents[T: Agent](
|
|
148
|
+
cls: type[T], model: Model, n: int, *args, **kwargs
|
|
149
|
+
) -> AgentSet[T]:
|
|
150
|
+
"""Create N agents.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
model: the model to which the agents belong
|
|
154
|
+
args: arguments to pass onto agent instances
|
|
155
|
+
each arg is either a single object or a sequence of length n
|
|
156
|
+
n: the number of agents to create
|
|
157
|
+
kwargs: keyword arguments to pass onto agent instances
|
|
158
|
+
each keyword arg is either a single object or a sequence of length n
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
AgentSet containing the agents created.
|
|
162
|
+
|
|
163
|
+
Warning:
|
|
164
|
+
A list, tuple, ndarray, or pandas Series argument is treated as one
|
|
165
|
+
value per agent and must have length n; a length mismatch raises
|
|
166
|
+
ValueError. This is especially easy to hit with coordinate tuples:
|
|
167
|
+
create_agents(model, 2, pos=(10, 20)) does NOT give both agents
|
|
168
|
+
pos=(10, 20); it gives agent 0 pos=10 and agent 1 pos=20, since the
|
|
169
|
+
tuple's length (2) matches n (2).
|
|
170
|
+
|
|
171
|
+
To assign the same sequence value to every agent, broadcast it
|
|
172
|
+
explicitly as a length-n list of that value, e.g.:
|
|
173
|
+
create_agents(model, 2, pos=[(10, 20)] * 2)
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ValueError: If a sequence argument's length does not match n.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
agents = []
|
|
180
|
+
|
|
181
|
+
if not args and not kwargs:
|
|
182
|
+
for _ in range(n):
|
|
183
|
+
agents.append(cls(model))
|
|
184
|
+
return AgentSet(agents, random=model.random)
|
|
185
|
+
|
|
186
|
+
# Prepare positional argument iterators. A sequence must have length n
|
|
187
|
+
# (assigned per agent); a length mismatch raises. Anything else is broadcast.
|
|
188
|
+
arg_iters = [_resolve_per_agent_values(arg, n) for arg in args]
|
|
189
|
+
|
|
190
|
+
# Prepare keyword argument iterators
|
|
191
|
+
kw_keys = list(kwargs.keys())
|
|
192
|
+
kw_val_iters = [_resolve_per_agent_values(v, n) for v in kwargs.values()]
|
|
193
|
+
|
|
194
|
+
# If arg_iters is empty, zip(*[]) returns nothing, so we use repeat(())
|
|
195
|
+
pos_iter = zip(*arg_iters) if arg_iters else itertools.repeat(())
|
|
196
|
+
|
|
197
|
+
kw_iter = zip(*kw_val_iters) if kw_val_iters else itertools.repeat(())
|
|
198
|
+
|
|
199
|
+
# We rely on range(n) to drive the loop length
|
|
200
|
+
if kwargs:
|
|
201
|
+
for _, p_args, k_vals in zip(range(n), pos_iter, kw_iter):
|
|
202
|
+
agents.append(cls(model, *p_args, **dict(zip(kw_keys, k_vals))))
|
|
203
|
+
else:
|
|
204
|
+
for _, p_args in zip(range(n), pos_iter):
|
|
205
|
+
agents.append(cls(model, *p_args))
|
|
206
|
+
|
|
207
|
+
return AgentSet(agents, random=model.random)
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_dataframe[T: Agent](
|
|
211
|
+
cls: type[T], model: Model, df: pd.DataFrame, **kwargs
|
|
212
|
+
) -> AgentSet[T]:
|
|
213
|
+
"""Create agents from a pandas DataFrame.
|
|
214
|
+
|
|
215
|
+
Each row of the DataFrame represents one agent. The DataFrame columns are
|
|
216
|
+
mapped to the agent's constructor as keyword arguments. Additional keyword
|
|
217
|
+
arguments (`**kwargs`) can be used to set constant attributes for all agents.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
model: The model instance.
|
|
221
|
+
df: The pandas DataFrame. Each row represents an agent.
|
|
222
|
+
**kwargs: Constant values to pass to every agent's constructor.
|
|
223
|
+
Only non-sequence data is allowed in kwargs to avoid ambiguity
|
|
224
|
+
with DataFrame columns.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
AgentSet containing the agents created.
|
|
228
|
+
|
|
229
|
+
Note:
|
|
230
|
+
If you need to pass variable data or sequences, add them as columns
|
|
231
|
+
to the DataFrame before calling this method.
|
|
232
|
+
"""
|
|
233
|
+
for key, value in kwargs.items():
|
|
234
|
+
if isinstance(value, (list, np.ndarray, tuple, pd.Series)):
|
|
235
|
+
raise TypeError(
|
|
236
|
+
f"from_dataframe does not support sequence data in kwargs ('{key}'). "
|
|
237
|
+
"Please add this data to the DataFrame before calling from_dataframe."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
agents = [
|
|
241
|
+
cls(model, **{**record, **kwargs})
|
|
242
|
+
for record in df.to_dict(orient="records")
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
return AgentSet(agents, random=model.random)
|
|
246
|
+
|
|
247
|
+
def __str__(self) -> str:
|
|
248
|
+
"""Return a human-readable string representation of the agent."""
|
|
249
|
+
return f"{self.__class__.__name__}, agent_id = {self.unique_id}"
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str:
|
|
252
|
+
"""Return an unambiguous string representation including agent state."""
|
|
253
|
+
# Get excluded fields (allows subclasses to override)
|
|
254
|
+
excluded = self._repr_excluded_fields
|
|
255
|
+
|
|
256
|
+
# Get user-defined attributes (exclude private and Modelly fields)
|
|
257
|
+
user_attrs = {
|
|
258
|
+
k: v
|
|
259
|
+
for k, v in self.__dict__.items()
|
|
260
|
+
if not k.startswith("_") and k not in excluded
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if user_attrs:
|
|
264
|
+
attr_str = ", ".join(f"{k}={v!r}" for k, v in user_attrs.items())
|
|
265
|
+
return f"<{self.__class__.__name__} id={self.unique_id} {attr_str}>"
|
|
266
|
+
else:
|
|
267
|
+
return f"<{self.__class__.__name__} id={self.unique_id}>"
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def random(self) -> Random:
|
|
271
|
+
"""Return a seeded stdlib rng."""
|
|
272
|
+
return self.model.random
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def rng(self) -> np.random.Generator:
|
|
276
|
+
"""Return a seeded np.random rng."""
|
|
277
|
+
return self.model.rng
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def scenario(self):
|
|
281
|
+
"""Return the scenario associated with the model."""
|
|
282
|
+
return self.model.scenario
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""AgentSet related classes.
|
|
2
|
+
|
|
3
|
+
Core Objects: AgentSet, AbstractAgentSet, _HardKeyAgentSet, GroupBy.
|
|
4
|
+
|
|
5
|
+
Storage model
|
|
6
|
+
-------------
|
|
7
|
+
Both concrete sets are *ordered sets* backed by a ``dict`` whose values are
|
|
8
|
+
always ``None``; the dict gives O(1) membership and removal while preserving
|
|
9
|
+
insertion order (so shuffles and sorts are stable and reproducible).
|
|
10
|
+
|
|
11
|
+
* :class:`AgentSet` keys the dict on ``weakref.ref`` objects. A removed agent
|
|
12
|
+
that nothing else references disappears from every ``AgentSet`` on its own.
|
|
13
|
+
This is the type every *view* (select, shuffle, sort, groupby, ...) returns.
|
|
14
|
+
* :class:`_HardKeyAgentSet` keys the dict on the agents themselves. It is used
|
|
15
|
+
only for the registries owned by :class:`~modelly.model.Model` whose lifecycle
|
|
16
|
+
is explicit (``Agent.__init__`` / ``Agent.remove``).
|
|
17
|
+
|
|
18
|
+
Every bulk operation first takes a C-level snapshot (``list(dict)``) and then
|
|
19
|
+
walks the snapshot, so user code that adds or removes agents mid-iteration can
|
|
20
|
+
never corrupt the dict being walked.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from modelly.agentset._utils import _resolve_per_agent_values, _resolve_weights
|
|
24
|
+
from modelly.agentset.abstract import AbstractAgentSet
|
|
25
|
+
from modelly.agentset.groupby import GroupBy
|
|
26
|
+
from modelly.agentset.strong import _HardKeyAgentSet
|
|
27
|
+
from modelly.agentset.weak import AgentSet
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AbstractAgentSet",
|
|
31
|
+
"AgentSet",
|
|
32
|
+
"GroupBy",
|
|
33
|
+
"_HardKeyAgentSet",
|
|
34
|
+
"_resolve_per_agent_values",
|
|
35
|
+
"_resolve_weights",
|
|
36
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Helpers shared by the AgentSet implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import math
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
9
|
+
from random import Random
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from modelly.agent import Agent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_MISSING = object()
|
|
20
|
+
_INF = float("inf")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_weights(
|
|
24
|
+
agents: Sequence[Agent],
|
|
25
|
+
weights: str | Callable[[Agent], float] | Sequence[float],
|
|
26
|
+
) -> list[float]:
|
|
27
|
+
"""Extract and validate numerical weights for a sequence of agents.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
agents: The sequence of agents to extract weights for.
|
|
31
|
+
weights: Agent attribute name (str), weight callable, or sequence of floats.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
list[float]: Validated list of non-negative weights with positive sum.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
TypeError: If weights is of an unsupported type.
|
|
38
|
+
ValueError: If sequence length does not match, if any weight is negative, or if total sum <= 0.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(weights, str):
|
|
41
|
+
w = [getattr(agent, weights) for agent in agents]
|
|
42
|
+
elif callable(weights):
|
|
43
|
+
w = [weights(agent) for agent in agents]
|
|
44
|
+
elif isinstance(weights, Sequence):
|
|
45
|
+
if len(weights) != len(agents):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Length of weights ({len(weights)}) does not match AgentSet length ({len(agents)})"
|
|
48
|
+
)
|
|
49
|
+
w = list(weights)
|
|
50
|
+
else:
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"Unsupported weights type: {type(weights).__name__}. "
|
|
53
|
+
"Expected str, Callable, Sequence[float], or None."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not all(map(math.isfinite, w)):
|
|
57
|
+
raise ValueError("All weights must be finite.")
|
|
58
|
+
if any(x < 0 for x in w):
|
|
59
|
+
raise ValueError("All weights must be non-negative.")
|
|
60
|
+
if sum(w) <= 0:
|
|
61
|
+
raise ValueError("Sum of weights must be strictly positive.")
|
|
62
|
+
|
|
63
|
+
return w
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_per_agent_values(value: Any, n: int, *, strict: bool = True) -> Iterable:
|
|
67
|
+
"""Resolve ``value`` into an iterable of ``n`` per-agent values.
|
|
68
|
+
|
|
69
|
+
A ``list``, ``tuple``, ``numpy.ndarray`` or ``pandas.Series`` of length ``n``
|
|
70
|
+
is treated as one value per agent. Any other value (a scalar, a string, or a
|
|
71
|
+
non-sequence) is broadcast to all ``n`` agents.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
value: The value(s) to assign, either one per agent or broadcast.
|
|
75
|
+
n: The number of agents to produce values for.
|
|
76
|
+
strict: If True (the default), a sequence whose length is not ``n`` raises
|
|
77
|
+
ValueError. If False, such a sequence is broadcast to every agent (the
|
|
78
|
+
whole sequence becomes each agent's value).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
An iterable yielding exactly ``n`` values.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If ``strict`` and ``value`` is a sequence whose length is
|
|
85
|
+
not ``n``.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(value, (list, tuple, np.ndarray, pd.Series)):
|
|
88
|
+
if len(value) == n:
|
|
89
|
+
return value
|
|
90
|
+
if strict:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"sequence of length {len(value)} does not match the number "
|
|
93
|
+
f"of agents ({n})"
|
|
94
|
+
)
|
|
95
|
+
return itertools.repeat(value, n)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_random(random: Random | None, first_agent: Agent | None) -> Random:
|
|
99
|
+
"""Pick the rng for a new set: the one given, else the first agent's model rng.
|
|
100
|
+
|
|
101
|
+
Falls back to a fresh, unseeded ``Random`` (with a warning) when the set is
|
|
102
|
+
empty and no rng was given, since there is then nothing to derive one from.
|
|
103
|
+
"""
|
|
104
|
+
if random is not None:
|
|
105
|
+
return random
|
|
106
|
+
if first_agent is not None:
|
|
107
|
+
# all agents in an AgentSet should share the same model, just take it from first
|
|
108
|
+
return first_agent.model.random
|
|
109
|
+
warnings.warn(
|
|
110
|
+
"No Agents specified in creation of AgentSet and no random number generator specified. "
|
|
111
|
+
"This can make models non-reproducible. Please pass a random number generator explicitly",
|
|
112
|
+
UserWarning,
|
|
113
|
+
stacklevel=3,
|
|
114
|
+
)
|
|
115
|
+
return Random()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_at_most(at_most: int | float, size: int) -> int | float:
|
|
119
|
+
"""Validate ``at_most`` for select-like methods and resolve fractions to counts."""
|
|
120
|
+
if isinstance(at_most, bool) or not isinstance(at_most, (int, float)):
|
|
121
|
+
raise TypeError(
|
|
122
|
+
f"at_most must be an integer or float, got {type(at_most).__name__}."
|
|
123
|
+
)
|
|
124
|
+
if isinstance(at_most, float) and at_most != _INF:
|
|
125
|
+
if not (0.0 < at_most <= 1.0):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Fractional at_most must be in the range (0.0, 1.0], got {at_most}."
|
|
128
|
+
)
|
|
129
|
+
return int(size * at_most) # Note that it rounds down (floor)
|
|
130
|
+
if isinstance(at_most, int) and at_most < 0:
|
|
131
|
+
raise ValueError(f"Integer at_most must be non-negative, got {at_most}.")
|
|
132
|
+
return at_most
|