Edit on GitHub

vi.metrics

Violet automatically collects the following data for every agent on every frame of the simulation:

  • The current frame of the simulation
  • The agent's identifier
  • The agent's current x and y position
  • The agent's current image_index
  • The agent's current angle if image_rotation is enabled

All this data is automatically saved into a Polars DataFrame and is provided by the Simulation's run method. To print a preview of the DataFrame, simply access the snapshots property.

>>> from vi import Agent, Config, Simulation
>>>
>>>
>>> print(
...     Simulation(Config(duration=60, seed=1))
...     .batch_spawn_agents(100, Agent, images=["examples/images/white.png"])
...     .run()
...     .snapshots # 👈 here we access the data of all agents
... )
shape: (6100, 5)
┌───────┬─────┬─────┬─────┬─────────────┐
│ frame ┆ id  ┆ x   ┆ y   ┆ image_index │
│ ---   ┆ --- ┆ --- ┆ --- ┆ ---         │
│ i64   ┆ i64 ┆ i64 ┆ i64 ┆ i64         │
╞═══════╪═════╪═════╪═════╪═════════════╡
│ 0     ┆ 0   ┆ 636 ┆ 573 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 0     ┆ 1   ┆ 372 ┆ 338 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 0     ┆ 2   ┆ 591 ┆ 70  ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 0     ┆ 3   ┆ 627 ┆ 325 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ ...   ┆ ... ┆ ... ┆ ... ┆ ...         │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 60    ┆ 96  ┆ 112 ┆ 151 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 60    ┆ 97  ┆ 710 ┆ 390 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 60    ┆ 98  ┆ 483 ┆ 167 ┆ 0           │
├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ 60    ┆ 99  ┆ 204 ┆ 170 ┆ 0           │
└───────┴─────┴─────┴─────┴─────────────┘

If we want to perform multiple, separate calculations on top of this DataFrame, then it makes sense to save the snapshots property to a variable. In addition, we can utilise vi.simulation.HeadlessSimulation to hide our simulation's window and improve performance.

>>> from vi import Agent, Config, HeadlessSimulation
>>>
>>>
>>> df = ( # 👈 assign to variable
...     HeadlessSimulation(Config(duration=60, seed=1))
...     .batch_spawn_agents(100, Agent, images=["examples/images/white.png"])
...     .run()
...     .snapshots
... )

We can now print multiple facts, such as the number of unique agent identifiers.

>>> print(df.get_column("id").n_unique())
100

Or perhaps we want to print a statistical summary for the x and y coordinates.

>>> print(df.select(["x", "y"]).describe())
shape: (5, 3)
┌──────────┬────────────┬────────────┐
│ describe ┆ x          ┆ y          │
│ ---      ┆ ---        ┆ ---        │
│ str      ┆ f64        ┆ f64        │
╞══════════╪════════════╪════════════╡
│ mean     ┆ 390.357213 ┆ 380.571475 │
├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
│ std      ┆ 213.594341 ┆ 226.671752 │
├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
│ min      ┆ 0.0        ┆ 0.0        │
├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
│ max      ┆ 750.0      ┆ 750.0      │
├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
│ median   ┆ 389.0      ┆ 361.0      │
└──────────┴────────────┴────────────┘

A construct that you'll likely use a lot is aggregation. Combined with the agg method, we can use group_by to group our rows by frame, so we can calculate the sum/mean/min/max/... of any expression.

By grouping our DataFrame by the frame column, we now have 100 rows (one for each agent) accessible to our aggregations. To calculate the mean x and y coordinate (over all agents) for every frame, we simply add two expressions to our aggregation.

>>> import polars as pl
>>>
>>>
>>> print(
...     df.group_by("frame", maintain_order=True)
...     .agg([
...         pl.col("x").mean().alias("x_mean"),
...         pl.col("y").mean().alias("y_mean"),
...     ])
...     .head()
... )
shape: (5, 3)
┌───────┬────────┬────────┐
│ frame ┆ x_mean ┆ y_mean │
│ ---   ┆ ---    ┆ ---    │
│ i64   ┆ f64    ┆ f64    │
╞═══════╪════════╪════════╡
│ 0     ┆ 393.23 ┆ 377.77 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 1     ┆ 393.35 ┆ 377.78 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 2     ┆ 393.31 ┆ 377.78 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 3     ┆ 393.42 ┆ 377.76 │
├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
│ 4     ┆ 393.42 ┆ 377.8  │
└───────┴────────┴────────┘

Notice that columns that do not appear in our list of aggregations also do not appear in our new DataFrame. In the example above, the id and image_index columns were dropped.

Need some more inspiration? Check out Polars' documentation!

Adding your own data

Extending Violet's snapshots DataFrame can easily be done by calling vi.agent.Agent.save_data. Some examples of data that might be useful to save are:

  • The number of agents in proximity of the current agent

    >>> class ProximityAgent(Agent):
    ...     def update(self):
    ...         in_proximity = self.in_proximity_accuracy().count()
    ...         self.save_data("in_proximity", in_proximity)
    
  • The name of the agent's class if you have multiple types of agents

    >>> class Bird(Agent):
    ...     def update(self):
    ...         self.save_data("kind", "bird")
    
    >>> class Fish(Agent):
    ...     def update(self):
    ...         self.save_data("kind", "fish")
    
  • The config value that you are testing when utilising vi.config.Matrix

    >>> class Neo(Agent):
    ...     def update(self):
    ...         self.save_data("radius", self.config.radius)
    
  • And anything else that you want to add to your DataFrame! 😎

One thing to keep in mind is that your save_data call cannot be conditional. Instead, it should be called for every agent on every frame. E.g. the following conditional call will result in a crash:

>>> class Bob(Agent):
...     def update(self):
...         if self.on_site():
...             self.save_data("on_site", True)

One way to fix this is to also call save_data when self.on_site evalues to False.

>>> class Bob(Agent):
...     def update(self):
...         if self.on_site():
...             self.save_data("on_site", True)
...         else:
...             self.save_data("on_site", False)

Of course, you can also save boolean values directly.

>>> class Bob(Agent):
...     def update(self):
...         self.save_data("on_site", self.on_site())
  1"""Violet automatically collects the following data for every agent on every frame of the simulation:
  2
  3- The current frame of the simulation
  4- The agent's identifier
  5- The agent's current `x` and `y` position
  6- The agent's current `image_index`
  7- The agent's current `angle` if `image_rotation` is enabled
  8
  9All this data is automatically saved into a [Polars](https://docs.pola.rs) DataFrame
 10and is provided by the Simulation's `run` method.
 11To print a preview of the DataFrame, simply access the `snapshots` property.
 12
 13>>> from vi import Agent, Config, Simulation
 14>>>
 15>>>
 16>>> print(
 17...     Simulation(Config(duration=60, seed=1))
 18...     .batch_spawn_agents(100, Agent, images=["examples/images/white.png"])
 19...     .run()
 20...     .snapshots # 👈 here we access the data of all agents
 21... )
 22shape: (6100, 5)
 23┌───────┬─────┬─────┬─────┬─────────────┐
 24│ frame ┆ id  ┆ x   ┆ y   ┆ image_index │
 25│ ---   ┆ --- ┆ --- ┆ --- ┆ ---         │
 26│ i64   ┆ i64 ┆ i64 ┆ i64 ┆ i64         │
 27╞═══════╪═════╪═════╪═════╪═════════════╡
 28│ 0     ┆ 0   ┆ 636 ┆ 573 ┆ 0           │
 29├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 30│ 0     ┆ 1   ┆ 372 ┆ 338 ┆ 0           │
 31├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 32│ 0     ┆ 2   ┆ 591 ┆ 70  ┆ 0           │
 33├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 34│ 0     ┆ 3   ┆ 627 ┆ 325 ┆ 0           │
 35├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 36│ ...   ┆ ... ┆ ... ┆ ... ┆ ...         │
 37├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 38│ 60    ┆ 96  ┆ 112 ┆ 151 ┆ 0           │
 39├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 40│ 60    ┆ 97  ┆ 710 ┆ 390 ┆ 0           │
 41├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 42│ 60    ┆ 98  ┆ 483 ┆ 167 ┆ 0           │
 43├╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┤
 44│ 60    ┆ 99  ┆ 204 ┆ 170 ┆ 0           │
 45└───────┴─────┴─────┴─────┴─────────────┘
 46
 47If we want to perform multiple, separate calculations on top of this DataFrame,
 48then it makes sense to save the `snapshots` property to a variable.
 49In addition, we can utilise `vi.simulation.HeadlessSimulation` to hide our simulation's window and improve performance.
 50
 51>>> from vi import Agent, Config, HeadlessSimulation
 52>>>
 53>>>
 54>>> df = ( # 👈 assign to variable
 55...     HeadlessSimulation(Config(duration=60, seed=1))
 56...     .batch_spawn_agents(100, Agent, images=["examples/images/white.png"])
 57...     .run()
 58...     .snapshots
 59... )
 60
 61We can now print multiple facts, such as the number of unique agent identifiers.
 62
 63>>> print(df.get_column("id").n_unique())
 64100
 65
 66Or perhaps we want to print a statistical summary for the `x` and `y` coordinates.
 67
 68>>> print(df.select(["x", "y"]).describe())
 69shape: (5, 3)
 70┌──────────┬────────────┬────────────┐
 71│ describe ┆ x          ┆ y          │
 72│ ---      ┆ ---        ┆ ---        │
 73│ str      ┆ f64        ┆ f64        │
 74╞══════════╪════════════╪════════════╡
 75│ mean     ┆ 390.357213 ┆ 380.571475 │
 76├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
 77│ std      ┆ 213.594341 ┆ 226.671752 │
 78├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
 79│ min      ┆ 0.0        ┆ 0.0        │
 80├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
 81│ max      ┆ 750.0      ┆ 750.0      │
 82├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
 83│ median   ┆ 389.0      ┆ 361.0      │
 84└──────────┴────────────┴────────────┘
 85
 86A construct that you'll likely use a lot is [aggregation](https://docs.pola.rs/user-guide/expressions/aggregation/).
 87Combined with the `agg` method, we can use `group_by` to group our rows by `frame`,
 88so we can calculate the sum/mean/min/max/... of any expression.
 89
 90By grouping our DataFrame by the `frame` column,
 91we now have 100 rows (one for each agent) accessible to our aggregations.
 92To calculate the mean `x` and `y` coordinate (over all agents) for every `frame`,
 93we simply add two expressions to our aggregation.
 94
 95>>> import polars as pl
 96>>>
 97>>>
 98>>> print(
 99...     df.group_by("frame", maintain_order=True)
100...     .agg([
101...         pl.col("x").mean().alias("x_mean"),
102...         pl.col("y").mean().alias("y_mean"),
103...     ])
104...     .head()
105... )
106shape: (5, 3)
107┌───────┬────────┬────────┐
108│ frame ┆ x_mean ┆ y_mean │
109│ ---   ┆ ---    ┆ ---    │
110│ i64   ┆ f64    ┆ f64    │
111╞═══════╪════════╪════════╡
112│ 0     ┆ 393.23 ┆ 377.77 │
113├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
114│ 1     ┆ 393.35 ┆ 377.78 │
115├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
116│ 2     ┆ 393.31 ┆ 377.78 │
117├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
118│ 3     ┆ 393.42 ┆ 377.76 │
119├╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
120│ 4     ┆ 393.42 ┆ 377.8  │
121└───────┴────────┴────────┘
122
123Notice that columns that do not appear in our list of aggregations also do not appear in our new DataFrame.
124In the example above, the `id` and `image_index` columns were dropped.
125
126Need some more inspiration? Check out [Polars' documentation](https://docs.pola.rs/user-guide/expressions/)!
127
128Adding your own data
129--------------------
130
131Extending Violet's snapshots DataFrame can easily be done by calling `vi.agent.Agent.save_data`.
132Some examples of data that might be useful to save are:
133
134-   The number of agents in proximity of the current agent
135
136    >>> class ProximityAgent(Agent):
137    ...     def update(self):
138    ...         in_proximity = self.in_proximity_accuracy().count()
139    ...         self.save_data("in_proximity", in_proximity)
140
141-   The name of the agent's class if you have multiple types of agents
142
143    >>> class Bird(Agent):
144    ...     def update(self):
145    ...         self.save_data("kind", "bird")
146
147    >>> class Fish(Agent):
148    ...     def update(self):
149    ...         self.save_data("kind", "fish")
150
151-   The config value that you are testing when utilising `vi.config.Matrix`
152
153    >>> class Neo(Agent):
154    ...     def update(self):
155    ...         self.save_data("radius", self.config.radius)
156
157-   And anything else that you want to add to your DataFrame! 😎
158
159One thing to keep in mind is that your `save_data` call cannot be conditional.
160Instead, it should be called for every agent on every frame.
161E.g. the following conditional call will result in a crash:
162
163>>> class Bob(Agent):
164...     def update(self):
165...         if self.on_site():
166...             self.save_data("on_site", True)
167
168One way to fix this is to also call `save_data` when `self.on_site` evalues to `False`.
169
170>>> class Bob(Agent):
171...     def update(self):
172...         if self.on_site():
173...             self.save_data("on_site", True)
174...         else:
175...             self.save_data("on_site", False)
176
177Of course, you can also save boolean values directly.
178
179>>> class Bob(Agent):
180...     def update(self):
181...         self.save_data("on_site", self.on_site())
182"""  # noqa: D415
183
184from __future__ import annotations
185
186from collections import defaultdict
187from dataclasses import dataclass, field
188from typing import TYPE_CHECKING
189
190import polars as pl
191
192
193if TYPE_CHECKING:
194    from typing import Any
195
196__all__ = [
197    "Fps",
198    "Metrics",
199]
200
201
202@dataclass
203class Fps:
204    _fps: list[float] = field(default_factory=list[float])
205
206    def _push(self, fps: float) -> None:
207        self._fps.append(fps)
208
209    def to_polars(self) -> pl.Series:
210        import polars as pl
211
212        return pl.Series("fps", self._fps)
213
214
215class Metrics:
216    """A container hosting all the accumulated simulation data over time."""
217
218    fps: Fps
219    """The frames-per-second history to analyse performance."""
220
221    _temporary_snapshots: defaultdict[str, list[Any]]
222
223    snapshots: pl.DataFrame
224    """The [Polars DataFrame](https://docs.pola.rs/api/python/stable/reference/dataframe/index.html) containing the snapshot data of all agents over time."""
225
226    def __init__(self) -> None:
227        self.fps = Fps()
228        self._temporary_snapshots = defaultdict(list)
229        self.snapshots = pl.DataFrame()
230
231    def _merge(self) -> None:
232        snapshots = pl.from_dict(self._temporary_snapshots)
233
234        self.snapshots.vstack(snapshots, in_place=True)
235
236        self._temporary_snapshots = defaultdict(list)
@dataclass
class Fps:
203@dataclass
204class Fps:
205    _fps: list[float] = field(default_factory=list[float])
206
207    def _push(self, fps: float) -> None:
208        self._fps.append(fps)
209
210    def to_polars(self) -> pl.Series:
211        import polars as pl
212
213        return pl.Series("fps", self._fps)
Fps(_fps: list[float] = <factory>)
def to_polars(self) -> polars.series.series.Series:
210    def to_polars(self) -> pl.Series:
211        import polars as pl
212
213        return pl.Series("fps", self._fps)
class Metrics:
216class Metrics:
217    """A container hosting all the accumulated simulation data over time."""
218
219    fps: Fps
220    """The frames-per-second history to analyse performance."""
221
222    _temporary_snapshots: defaultdict[str, list[Any]]
223
224    snapshots: pl.DataFrame
225    """The [Polars DataFrame](https://docs.pola.rs/api/python/stable/reference/dataframe/index.html) containing the snapshot data of all agents over time."""
226
227    def __init__(self) -> None:
228        self.fps = Fps()
229        self._temporary_snapshots = defaultdict(list)
230        self.snapshots = pl.DataFrame()
231
232    def _merge(self) -> None:
233        snapshots = pl.from_dict(self._temporary_snapshots)
234
235        self.snapshots.vstack(snapshots, in_place=True)
236
237        self._temporary_snapshots = defaultdict(list)

A container hosting all the accumulated simulation data over time.

fps: Fps

The frames-per-second history to analyse performance.

snapshots: polars.dataframe.frame.DataFrame

The Polars DataFrame containing the snapshot data of all agents over time.