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.
Files changed (160) hide show
  1. modelly/__init__.py +37 -0
  2. modelly/_native/__init__.py +0 -0
  3. modelly/agent.py +282 -0
  4. modelly/agentset/__init__.py +36 -0
  5. modelly/agentset/_utils.py +132 -0
  6. modelly/agentset/abstract.py +352 -0
  7. modelly/agentset/groupby.py +142 -0
  8. modelly/agentset/selection.py +163 -0
  9. modelly/agentset/strong.py +160 -0
  10. modelly/agentset/weak.py +274 -0
  11. modelly/batch.py +140 -0
  12. modelly/cohorts.py +198 -0
  13. modelly/column_access.py +81 -0
  14. modelly/columns.py +301 -0
  15. modelly/datacollection.py +394 -0
  16. modelly/discrete_space/__init__.py +55 -0
  17. modelly/discrete_space/base_grid.py +325 -0
  18. modelly/discrete_space/cell.py +317 -0
  19. modelly/discrete_space/cell_agent.py +226 -0
  20. modelly/discrete_space/cell_collection.py +189 -0
  21. modelly/discrete_space/cell_index.py +138 -0
  22. modelly/discrete_space/discrete_space.py +302 -0
  23. modelly/discrete_space/grid.py +230 -0
  24. modelly/discrete_space/network.py +181 -0
  25. modelly/discrete_space/occupancy.py +113 -0
  26. modelly/discrete_space/property_layer.py +146 -0
  27. modelly/discrete_space/voronoi.py +315 -0
  28. modelly/examples/README.md +54 -0
  29. modelly/examples/__init__.py +25 -0
  30. modelly/examples/advanced/__init__.py +0 -0
  31. modelly/examples/advanced/alliance_formation/Readme.md +45 -0
  32. modelly/examples/advanced/alliance_formation/__init__.py +0 -0
  33. modelly/examples/advanced/alliance_formation/agents.py +20 -0
  34. modelly/examples/advanced/alliance_formation/app.py +83 -0
  35. modelly/examples/advanced/alliance_formation/model.py +201 -0
  36. modelly/examples/advanced/epstein_civil_violence/Epstein Civil Violence.ipynb +116 -0
  37. modelly/examples/advanced/epstein_civil_violence/Readme.md +38 -0
  38. modelly/examples/advanced/epstein_civil_violence/__init__.py +0 -0
  39. modelly/examples/advanced/epstein_civil_violence/agents.py +140 -0
  40. modelly/examples/advanced/epstein_civil_violence/app.py +92 -0
  41. modelly/examples/advanced/epstein_civil_violence/model.py +125 -0
  42. modelly/examples/advanced/pd_grid/Readme.md +45 -0
  43. modelly/examples/advanced/pd_grid/__init__.py +0 -0
  44. modelly/examples/advanced/pd_grid/agents.py +48 -0
  45. modelly/examples/advanced/pd_grid/analysis.ipynb +183 -0
  46. modelly/examples/advanced/pd_grid/app.py +62 -0
  47. modelly/examples/advanced/pd_grid/model.py +89 -0
  48. modelly/examples/advanced/sugarscape_g1mt/Readme.md +71 -0
  49. modelly/examples/advanced/sugarscape_g1mt/__init__.py +0 -0
  50. modelly/examples/advanced/sugarscape_g1mt/agents.py +301 -0
  51. modelly/examples/advanced/sugarscape_g1mt/app.py +82 -0
  52. modelly/examples/advanced/sugarscape_g1mt/model.py +134 -0
  53. modelly/examples/advanced/sugarscape_g1mt/sugar-map.txt +50 -0
  54. modelly/examples/advanced/wolf_sheep/Readme.md +46 -0
  55. modelly/examples/advanced/wolf_sheep/__init__.py +0 -0
  56. modelly/examples/advanced/wolf_sheep/agents.py +154 -0
  57. modelly/examples/advanced/wolf_sheep/app.py +92 -0
  58. modelly/examples/advanced/wolf_sheep/model.py +146 -0
  59. modelly/examples/basic/__init__.py +0 -0
  60. modelly/examples/basic/boid_flockers/Readme.md +31 -0
  61. modelly/examples/basic/boid_flockers/__init__.py +0 -0
  62. modelly/examples/basic/boid_flockers/agents.py +93 -0
  63. modelly/examples/basic/boid_flockers/app.py +88 -0
  64. modelly/examples/basic/boid_flockers/model.py +123 -0
  65. modelly/examples/basic/boltzmann_wealth_model/Readme.md +63 -0
  66. modelly/examples/basic/boltzmann_wealth_model/__init__.py +0 -0
  67. modelly/examples/basic/boltzmann_wealth_model/agents.py +48 -0
  68. modelly/examples/basic/boltzmann_wealth_model/app.py +99 -0
  69. modelly/examples/basic/boltzmann_wealth_model/model.py +97 -0
  70. modelly/examples/basic/boltzmann_wealth_model/st_app.py +117 -0
  71. modelly/examples/basic/conways_game_of_life/Readme.md +38 -0
  72. modelly/examples/basic/conways_game_of_life/__init__.py +0 -0
  73. modelly/examples/basic/conways_game_of_life/agents.py +55 -0
  74. modelly/examples/basic/conways_game_of_life/app.py +73 -0
  75. modelly/examples/basic/conways_game_of_life/model.py +36 -0
  76. modelly/examples/basic/conways_game_of_life/st_app.py +73 -0
  77. modelly/examples/basic/schelling/Readme.md +37 -0
  78. modelly/examples/basic/schelling/__init__.py +0 -0
  79. modelly/examples/basic/schelling/agents.py +47 -0
  80. modelly/examples/basic/schelling/app.py +93 -0
  81. modelly/examples/basic/schelling/model.py +93 -0
  82. modelly/examples/basic/schelling/resources/blue_happy.png +0 -0
  83. modelly/examples/basic/schelling/resources/blue_unhappy.png +0 -0
  84. modelly/examples/basic/schelling/resources/orange_happy.png +0 -0
  85. modelly/examples/basic/schelling/resources/orange_unhappy.png +0 -0
  86. modelly/examples/basic/virus_on_network/Readme.md +44 -0
  87. modelly/examples/basic/virus_on_network/__init__.py +0 -0
  88. modelly/examples/basic/virus_on_network/agents.py +65 -0
  89. modelly/examples/basic/virus_on_network/app.py +140 -0
  90. modelly/examples/basic/virus_on_network/model.py +92 -0
  91. modelly/examples/experimental/__init__.py +0 -0
  92. modelly/examples/experimental/tram_model/Readme.md +67 -0
  93. modelly/examples/experimental/tram_model/__init__.py +0 -0
  94. modelly/examples/experimental/tram_model/agents.py +158 -0
  95. modelly/examples/experimental/tram_model/app.py +65 -0
  96. modelly/examples/experimental/tram_model/model.py +116 -0
  97. modelly/examples/experimental/tram_model/utils.py +223 -0
  98. modelly/exceptions.py +92 -0
  99. modelly/experimental/__init__.py +18 -0
  100. modelly/experimental/actions/__init__.py +9 -0
  101. modelly/experimental/actions/actions.py +721 -0
  102. modelly/experimental/continuous_space/__init__.py +19 -0
  103. modelly/experimental/continuous_space/continuous_space.py +341 -0
  104. modelly/experimental/continuous_space/continuous_space_agents.py +93 -0
  105. modelly/experimental/continuous_space/vectorised.py +64 -0
  106. modelly/experimental/data_collection/__init__.py +32 -0
  107. modelly/experimental/data_collection/basedatarecorder.py +303 -0
  108. modelly/experimental/data_collection/datarecorders.py +693 -0
  109. modelly/experimental/data_collection/dataset.py +628 -0
  110. modelly/experimental/meta_agents/__init__.py +9 -0
  111. modelly/experimental/meta_agents/backend.py +135 -0
  112. modelly/experimental/meta_agents/meta_agent.py +191 -0
  113. modelly/experimental/meta_agents/meta_agents_api.py +459 -0
  114. modelly/experimental/scenarios/__init__.py +27 -0
  115. modelly/experimental/scenarios/exceptions.py +210 -0
  116. modelly/experimental/scenarios/runner.py +280 -0
  117. modelly/experimental/scenarios/scenario.py +344 -0
  118. modelly/experimental/scenarios/store.py +257 -0
  119. modelly/experimental/signals/__init__.py +37 -0
  120. modelly/experimental/signals/batching.py +275 -0
  121. modelly/experimental/signals/core.py +775 -0
  122. modelly/experimental/signals/observable_collections.py +192 -0
  123. modelly/experimental/signals/signal_types.py +87 -0
  124. modelly/experimental/signals/signals_util.py +75 -0
  125. modelly/experimental/states/__init__.py +5 -0
  126. modelly/experimental/states/state.py +492 -0
  127. modelly/lint/__init__.py +82 -0
  128. modelly/lint/__main__.py +85 -0
  129. modelly/lint/checker.py +240 -0
  130. modelly/lint/rules.py +182 -0
  131. modelly/log.py +197 -0
  132. modelly/migrate.py +264 -0
  133. modelly/model.py +330 -0
  134. modelly/py.typed +0 -0
  135. modelly/reporters.py +244 -0
  136. modelly/rng.py +205 -0
  137. modelly/scheduling.py +155 -0
  138. modelly/time/__init__.py +15 -0
  139. modelly/time/events.py +600 -0
  140. modelly/visualization/__init__.py +29 -0
  141. modelly/visualization/backends/__init__.py +23 -0
  142. modelly/visualization/backends/abstract_renderer.py +120 -0
  143. modelly/visualization/backends/altair_backend.py +467 -0
  144. modelly/visualization/backends/matplotlib_backend.py +430 -0
  145. modelly/visualization/command_console.py +482 -0
  146. modelly/visualization/components/__init__.py +103 -0
  147. modelly/visualization/components/altair_components.py +529 -0
  148. modelly/visualization/components/matplotlib_components.py +177 -0
  149. modelly/visualization/components/portrayal_components.py +123 -0
  150. modelly/visualization/mpl_space_drawing.py +855 -0
  151. modelly/visualization/solara_viz.py +931 -0
  152. modelly/visualization/space_drawers.py +852 -0
  153. modelly/visualization/space_renderer.py +485 -0
  154. modelly/visualization/user_param.py +69 -0
  155. modelly/visualization/utils.py +9 -0
  156. modelly_abm-1.0.0.dist-info/METADATA +663 -0
  157. modelly_abm-1.0.0.dist-info/RECORD +160 -0
  158. modelly_abm-1.0.0.dist-info/WHEEL +4 -0
  159. modelly_abm-1.0.0.dist-info/licenses/LICENSE +201 -0
  160. 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