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
andy
position - The agent's current
image_index
- The agent's current
angle
ifimage_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)
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.
The Polars DataFrame containing the snapshot data of all agents over time.