Edit on GitHub

vi.config

  1from __future__ import annotations
  2
  3from copy import deepcopy
  4from dataclasses import dataclass, field
  5from typing import TYPE_CHECKING
  6
  7
  8if TYPE_CHECKING:
  9    from typing import Any, Self, TypeIs
 10
 11
 12__all__ = [
 13    "Config",
 14    "Matrix",
 15    "Mono",
 16    "Poly",
 17    "Schema",
 18    "Window",
 19]
 20
 21
 22def _embiggen(input_list: list[Any], copies: int) -> None:
 23    """The in-place deep-copy variant of list multiplication."""
 24    head = input_list[:]
 25
 26    for _ in range(copies - 1):
 27        input_list.extend(deepcopy(head))
 28
 29
 30def _is_list(obj: Any) -> TypeIs[list[Any]]:  # noqa: ANN401
 31    return isinstance(obj, list)
 32
 33
 34def _matrixify(matrix: dict[str, Any | list[Any]]) -> list[dict[str, Any]]:  # noqa: C901
 35    combinations: list[dict[str, Any]] = []
 36
 37    for key, values in matrix.items():
 38        if key.startswith("_"):
 39            continue
 40
 41        # Skip this key if its value is an empty list
 42        if _is_list(values) and len(values) == 0:
 43            continue
 44
 45        # Initially the list is empty, so the dicts have to be
 46        # manually created on the first iteration.
 47        if len(combinations) == 0:
 48            # Multiple values
 49            if _is_list(values):
 50                combinations.extend({key: value} for value in values)
 51
 52            # Single value
 53            elif values is not None:
 54                combinations.append({key: values})
 55
 56        # If we have a list of dicts, we can simply add our key!
 57        elif _is_list(values):  # Multiple values
 58            original_length = len(combinations)
 59            _embiggen(combinations, len(values))
 60
 61            for index, entry in enumerate(combinations):
 62                value_index = index // original_length
 63                entry[key] = values[value_index]
 64
 65        elif values is not None:  # Single value
 66            for entry in combinations:
 67                entry[key] = values
 68
 69    for index, entry in enumerate(combinations):
 70        entry["id"] = index + 1
 71
 72    return combinations
 73
 74
 75@dataclass
 76class Window:
 77    """Settings related to the simulation window."""
 78
 79    width: int = 750
 80    """The width of the simulation window in pixels."""
 81
 82    height: int = 750
 83    """The height of the simulation window in pixels."""
 84
 85    @classmethod
 86    def square(cls, size: int) -> Self:
 87        return cls(width=size, height=size)
 88
 89    def as_tuple(self) -> tuple[int, int]:
 90        return (self.width, self.height)
 91
 92
 93type Mono[T] = T
 94type Poly[T] = T | list[T]
 95
 96
 97@dataclass
 98class Schema[Int: Poly[int], Float: Poly[float]]:
 99    """All values shared between `Config` and `Matrix`.
100
101    NOTE: DOCUMENTATION OF SCHEMA IS INCORRECT AND WILL BE UPDATED IN VERSION 0.3.1.
102    IF YOU ARE SEEING THIS MESSAGE, MAKE SURE TO UPDATE VIOLET.
103
104    A sprinkle of ✨ [magical typing](https://mypy.readthedocs.io/en/stable/generics.html) ✨ makes list values in the `Matrix` class possible without any overrides.
105    You'll notice that the `Schema` class is generic over two type parameters: `Int` and `Float`.
106    These type parameters can either be a single int/float value or a list of int/float values respectively.
107    In combination with the [`Union`](https://docs.python.org/3/library/typing.html#typing.Union) of int/float, both a `Matrix` and `Config` class can be derived.
108
109    Examples
110    --------
111    To build your own `Matrix`, you first want to create a `Schema` with the configuration options that you want to add.
112    For this example, let's say that we want to add an `infectability` option to our custom `Schema`.
113    We want this infectability to be a `float`.
114    However, in our `Matrix`, we want the possibility to pass multiple floats to automatically generate unique config combinations.
115
116    As we want our `infectability` to be a `float` or a `list[float]` depending on whether we have a `Config` or `Matrix` respectively,
117    we need to create a type parameter which will act as our placeholder.
118
119    ```python
120    from typing import TypeVar
121    MatrixFloat = TypeVar("MatrixFloat", float, list[float])
122    ```
123
124    Next up, we can create our custom `Schema`.
125    Note that we do not inherit `Schema`.
126    Instead, we state that our schema is generic over `MatrixFloat`.
127
128    ```python
129    from typing import Generic, Union
130    from vi.config import dataclass
131
132    @dataclass
133    class CovidSchema(Generic[MatrixFloat]):
134        infectability: Union[float, MatrixFloat] = 1.0
135    ```
136
137    By making the type of `infectability` a union of `float` and `MatrixFloat`,
138    we state that `infectability` will either be a `float` or a `MatrixFloat`.
139    But if you've been playing close attention,
140    you'll notice that our `MatrixFloat` itself can either be a `float` or a `list[float]`.
141    This little trick allows us to state that `infectability` can always be a `float`.
142    But if `MatrixFloat` is a `list[float]`, then `infectability` can be a `list[float]` as well.
143
144    Now, to create our own `ConfigConfig` and `ConfigMatrix`, we inherit `Config` and `Matrix` respectively.
145    However, we also inherit our `CovidSchema` which we just created.
146
147    ```python
148    from vi.config import Config, Matrix
149
150    @dataclass
151    class CovidConfig(Config, CovidSchema[float]):
152        ...
153
154    @dataclass
155    class CovidMatrix(Matrix, CovidSchema[list[float]]):
156        ...
157    ```
158
159    The classes themselves don't add any values.
160    Instead, we simply state that these classes combine `Config`/`Matrix` with their respective `CovidSchema`.
161    And here's where our generic `MatrixFloat` type parameter makes a return.
162    When inheriting `CovidSchema`, we state what the type of `MatrixFloat` must be.
163    By passing a `float` in `CovidConfig`, we ensure that the config will always have only one value for `infectability`.
164    Similarly, passing `list[float]` in `CovidMatrix` allows us to either supply a single value in the matrix,
165    or a list of values for `infectability`.
166
167    Sometimes, it does not make sense to make a value matrix-configurable.
168    In those cases, you do not have to create a `MatrixFloat`-like type parameter.
169    Instead, you can simply use a normal type annotation.
170
171    ```python
172    @dataclass
173    class CovidSchema:
174        infectability: float = 1.0
175    ```
176
177    Note that we still create a `Schema`, as you should never inherit and add values to `Matrix` directly.
178    Instead, you should always create a `Schema` and derive the config and matrix classes.
179
180    If you want to support both matrix floats as well as integers,
181    you can simply make your `Schema` generic over multiple type parameters.
182
183    ```python
184    MatrixFloat = TypeVar("MatrixFloat", float, list[float])
185    MatrixInt = TypeVar("MatrixInt", int, list[int])
186
187    @dataclass
188    class CovidSchema(Generic[MatrixFloat, MatrixInt]):
189        infectability: Union[float, MatrixFloat] = 1.0
190        recovery_time: Union[int, MatrixInt] = 60
191    ```
192
193    Just make sure to use the same order when deriving the `CovidConfig` and `CovidMatrix` classes.
194
195    ```python
196    @dataclass
197    class CovidConfig(Config, CovidSchema[float, int]):
198        #                                         👆
199
200    @dataclass
201    class CovidMatrix(Matrix, CovidSchema[list[float], list[int]]):
202        #            MatrixInt is on the second position too 👆
203    ```
204
205    """
206
207    id: int = 0
208    """The identifier of the config."""
209
210    duration: int = 0
211    """The duration of the simulation in frames.
212
213    Defaults to `0`, indicating that the simulation runs indefinitely.
214    """
215
216    fps_limit: int = 60
217    """Limit the number of frames-per-second.
218
219    Defaults to 60 fps, equal to most screens' refresh rates.
220
221    Set to `0` to uncap the framerate.
222    """
223
224    image_rotation: bool = False
225    """Opt-in image rotation support.
226
227    Please be aware that the rotation of images degrades performance by ~15%
228    and currently causes a bug where agents clip into obstacles.
229    """
230
231    movement_speed: float | Float = 0.5
232    """The per-frame movement speed of the agents."""
233
234    print_fps: bool = False
235    """Print the current number of frames-per-second in the terminal"""
236
237    radius: int | Int = 25
238    """The radius (in pixels) in which agents are considered to be in proximity."""
239
240    seed: int | Int | None = None
241    """The PRNG seed to use for the simulation.
242
243    Defaults to `None`, indicating that no seed is used.
244    """
245
246    visualise_chunks: bool = False
247    """Draw the borders of the proximity-chunks on screen."""
248
249    window: Window = field(default_factory=Window)
250    """The simulation window"""
251
252    def to_configs[T: Config](
253        self,
254        target: type[T],
255    ) -> list[T]:
256        """Generate a config for every unique combination of values in the matrix."""
257        return [target(**values) for values in _matrixify(self.__dict__)]
258
259
260Matrix = Schema[Poly[int], Poly[float]]
261"""`Matrix` is `Config` on steroids.
262
263It allows you to supply a list of values on certain configuration options,
264to automatically generate multiple unique `Config` instances.
265
266Examples
267--------
268Imagine that you want to research the effect of the `radius` parameter.
269Instead of only testing the default value of 25 pixels,
270you also want to test a radius of 10 and 50 pixels.
271
272A brute-force approach would be to create three unique `Config` instances manually.
273
274```python
275config1 = Config(radius=10)
276config2 = Config(radius=25)
277config3 = Config(radius=50)
278```
279
280However, perhaps we also want to override some other default values,
281such as adding a `duration` to the simulation.
282If we follow the same approach, then our code becomes messy rather quickly.
283
284```python
285config1 = Config(radius=10, duration=60 * 10)
286config2 = Config(radius=25, duration=60 * 10)
287config3 = Config(radius=50, duration=60 * 10)
288```
289
290So what do we do?
291
292We use `Matrix`! 😎
293
294`Matrix` allows us to write multiple configurations as if we are writing one configuration.
295If we want to test multiple values of `radius`, then we can simply supply a list of values.
296
297```python
298matrix = Matrix(duration=60 * 10, radius=[10, 25, 50])
299```
300
301It's that easy!
302Now, if we want to generate a `Config` for each of the values in the radius list,
303we can call the `to_configs` method.
304
305```python
306configs = matrix.to_configs(Config)
307```
308
309The list of configs returned by the `to_configs` method is equivalent to the brute-force approach we took earlier.
310However, by utilising `Matrix`, our code is way more compact and easier to read.
311
312And the fun doesn't stop there, as we can supply lists to multiple config options as well!
313Let's say that we not only want to test the effect of `radius`, but also the effect of `movement_speed`.
314We can simply pass a list of values to `movement_speed` and `Matrix` will automatically compute
315the unique `Config` combinations that it can make between the values of `radius` and `movement_speed`.
316
317```python
318matrix = Matrix(
319    duration=60 * 10,
320    radius=[10, 25, 50],
321    movement_speed=[0.5, 1.0],
322)
323```
324
325If we now check the number of configs generated,
326we will see that the above matrix produces 6 unique combinations (3 x 2).
327
328>>> len(matrix.to_configs(Config))
3296
330
331`Matrix` is an essential tool for analysing the effect of your simulation's parameters.
332It allows you to effortlessly create multiple configurations, while keeping your code tidy.
333
334Now, before you create a for-loop and iterate over the list of configs,
335allow me to introduce you to [multiprocessing](https://docs.python.org/3/library/multiprocessing.html).
336This built-in Python library allows us to run multiple simulations in parallel.
337
338As you might already know, your processor (or CPU) consists of multiple cores.
339Parallelisation allows us to run one simulation on every core of your CPU.
340So if you have a beefy 10-core CPU, you can run 10 simulations in the same time as running one simulation individually.
341
342However, your GPU might not be able to keep up with rendering 10 simulations at once.
343Therefore, it's best to switch to `HeadlessSimulation` when running multiple simulations in parallel,
344as this simulation class disables all the rendering-related logic.
345Thus, removing the GPU from the equation.
346
347To learn more about parallelisation, please check out the [multiprocessing documentation](https://docs.python.org/3/library/multiprocessing.html).
348For Violet, the following code is all you need to get started with parallelisation.
349
350```python
351from multiprocessing import Pool
352
353import polars as pl
354
355from vi import Agent, Config, HeadlessSimulation, Matrix
356
357
358class ParallelAgent(Agent):
359    config: Config
360
361    def update(self):
362        # We save the radius and seed config values to our DataFrame,
363        # so we can make comparisons between these config values later.
364        self.save_data("radius", self.config.radius)
365        self.save_data("seed", self.config.seed)
366
367
368def run_simulation(config: Config) -> pl.DataFrame:
369    return (
370        HeadlessSimulation(config)
371        .batch_spawn_agents(100, ParallelAgent, ["examples/images/white.png"])
372        .run()
373        .snapshots
374    )
375
376
377if __name__ == "__main__":
378    # We create a threadpool to run our simulations in parallel
379    with Pool() as p:
380        # The matrix will create four unique configs
381        matrix = Matrix(radius=[25, 50], seed=[1, 2])
382
383        # Create unique combinations of matrix values
384        configs = matrix.to_configs(Config)
385
386        # Combine our individual DataFrames into one big DataFrame
387        df = pl.concat(p.map(run_simulation, configs))
388
389        print(df)
390```
391
392"""
393
394
395Config = Schema[Mono[int], Mono[float]]
396"""`Config` allows you to tweak the settings of your experiment.
397
398Examples
399--------
400If you want to change the proximity `radius` of your agents,
401you can create a new `Config` instance and pass a custom value for `radius`.
402
403```python
404from vi import Agent, Config, Simulation
405
406
407(
408    #                   👇 we override the default radius value
409    Simulation(Config(radius=50))
410    .batch_spawn_agents(100, Agent, ["examples/images/white.png"])
411    .run()
412)
413```
414
415To add your own values to `Config`,
416you can simply inherit `Config`, decorate it with [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) and add your own options.
417However, make sure to declare the [type](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) of the configuration option
418along with its default value.
419
420```python
421@dataclass
422class MyConfig(Config):
423    #           👇 type
424    excitement: int = 100
425    #                  👆 default value
426```
427
428Last but not least, declare that your agent is using the `MyConfig` class
429and pass it along to the constructor of `Simulation`.
430
431```python
432#                      👇 use our custom config
433class MyAgent(Agent[MyConfig]): ...
434
435(
436    #             👇 here too!
437    Simulation(MyConfig())
438    .batch_spawn_agents(100, MyAgent, ["examples/images/white.png"])
439    .run()
440)
441```
442
443"""
Config = Schema[Mono[int], Mono[float]]

Config allows you to tweak the settings of your experiment.

Examples

If you want to change the proximity radius of your agents, you can create a new Config instance and pass a custom value for radius.

from vi import Agent, Config, Simulation


(
    #                   👇 we override the default radius value
    Simulation(Config(radius=50))
    .batch_spawn_agents(100, Agent, ["examples/images/white.png"])
    .run()
)

To add your own values to Config, you can simply inherit Config, decorate it with @dataclass and add your own options. However, make sure to declare the type of the configuration option along with its default value.

@dataclass
class MyConfig(Config):
    #           👇 type
    excitement: int = 100
    #                  👆 default value

Last but not least, declare that your agent is using the MyConfig class and pass it along to the constructor of Simulation.

#                      👇 use our custom config
class MyAgent(Agent[MyConfig]): ...

(
    #             👇 here too!
    Simulation(MyConfig())
    .batch_spawn_agents(100, MyAgent, ["examples/images/white.png"])
    .run()
)
Matrix = Schema[Poly[int], Poly[float]]

Matrix is Config on steroids.

It allows you to supply a list of values on certain configuration options, to automatically generate multiple unique Config instances.

Examples

Imagine that you want to research the effect of the radius parameter. Instead of only testing the default value of 25 pixels, you also want to test a radius of 10 and 50 pixels.

A brute-force approach would be to create three unique Config instances manually.

config1 = Config(radius=10)
config2 = Config(radius=25)
config3 = Config(radius=50)

However, perhaps we also want to override some other default values, such as adding a duration to the simulation. If we follow the same approach, then our code becomes messy rather quickly.

config1 = Config(radius=10, duration=60 * 10)
config2 = Config(radius=25, duration=60 * 10)
config3 = Config(radius=50, duration=60 * 10)

So what do we do?

We use Matrix! 😎

Matrix allows us to write multiple configurations as if we are writing one configuration. If we want to test multiple values of radius, then we can simply supply a list of values.

matrix = Matrix(duration=60 * 10, radius=[10, 25, 50])

It's that easy! Now, if we want to generate a Config for each of the values in the radius list, we can call the to_configs method.

configs = matrix.to_configs(Config)

The list of configs returned by the to_configs method is equivalent to the brute-force approach we took earlier. However, by utilising Matrix, our code is way more compact and easier to read.

And the fun doesn't stop there, as we can supply lists to multiple config options as well! Let's say that we not only want to test the effect of radius, but also the effect of movement_speed. We can simply pass a list of values to movement_speed and Matrix will automatically compute the unique Config combinations that it can make between the values of radius and movement_speed.

matrix = Matrix(
    duration=60 * 10,
    radius=[10, 25, 50],
    movement_speed=[0.5, 1.0],
)

If we now check the number of configs generated, we will see that the above matrix produces 6 unique combinations (3 x 2).

>>> len(matrix.to_configs(Config))
6

Matrix is an essential tool for analysing the effect of your simulation's parameters. It allows you to effortlessly create multiple configurations, while keeping your code tidy.

Now, before you create a for-loop and iterate over the list of configs, allow me to introduce you to multiprocessing. This built-in Python library allows us to run multiple simulations in parallel.

As you might already know, your processor (or CPU) consists of multiple cores. Parallelisation allows us to run one simulation on every core of your CPU. So if you have a beefy 10-core CPU, you can run 10 simulations in the same time as running one simulation individually.

However, your GPU might not be able to keep up with rendering 10 simulations at once. Therefore, it's best to switch to HeadlessSimulation when running multiple simulations in parallel, as this simulation class disables all the rendering-related logic. Thus, removing the GPU from the equation.

To learn more about parallelisation, please check out the multiprocessing documentation. For Violet, the following code is all you need to get started with parallelisation.

from multiprocessing import Pool

import polars as pl

from vi import Agent, Config, HeadlessSimulation, Matrix


class ParallelAgent(Agent):
    config: Config

    def update(self):
        # We save the radius and seed config values to our DataFrame,
        # so we can make comparisons between these config values later.
        self.save_data("radius", self.config.radius)
        self.save_data("seed", self.config.seed)


def run_simulation(config: Config) -> pl.DataFrame:
    return (
        HeadlessSimulation(config)
        .batch_spawn_agents(100, ParallelAgent, ["examples/images/white.png"])
        .run()
        .snapshots
    )


if __name__ == "__main__":
    # We create a threadpool to run our simulations in parallel
    with Pool() as p:
        # The matrix will create four unique configs
        matrix = Matrix(radius=[25, 50], seed=[1, 2])

        # Create unique combinations of matrix values
        configs = matrix.to_configs(Config)

        # Combine our individual DataFrames into one big DataFrame
        df = pl.concat(p.map(run_simulation, configs))

        print(df)
type Mono = T
type Poly = Union[T, list[T]]
@dataclass
class Schema(typing.Generic[Int, Float]):
 98@dataclass
 99class Schema[Int: Poly[int], Float: Poly[float]]:
100    """All values shared between `Config` and `Matrix`.
101
102    NOTE: DOCUMENTATION OF SCHEMA IS INCORRECT AND WILL BE UPDATED IN VERSION 0.3.1.
103    IF YOU ARE SEEING THIS MESSAGE, MAKE SURE TO UPDATE VIOLET.
104
105    A sprinkle of ✨ [magical typing](https://mypy.readthedocs.io/en/stable/generics.html) ✨ makes list values in the `Matrix` class possible without any overrides.
106    You'll notice that the `Schema` class is generic over two type parameters: `Int` and `Float`.
107    These type parameters can either be a single int/float value or a list of int/float values respectively.
108    In combination with the [`Union`](https://docs.python.org/3/library/typing.html#typing.Union) of int/float, both a `Matrix` and `Config` class can be derived.
109
110    Examples
111    --------
112    To build your own `Matrix`, you first want to create a `Schema` with the configuration options that you want to add.
113    For this example, let's say that we want to add an `infectability` option to our custom `Schema`.
114    We want this infectability to be a `float`.
115    However, in our `Matrix`, we want the possibility to pass multiple floats to automatically generate unique config combinations.
116
117    As we want our `infectability` to be a `float` or a `list[float]` depending on whether we have a `Config` or `Matrix` respectively,
118    we need to create a type parameter which will act as our placeholder.
119
120    ```python
121    from typing import TypeVar
122    MatrixFloat = TypeVar("MatrixFloat", float, list[float])
123    ```
124
125    Next up, we can create our custom `Schema`.
126    Note that we do not inherit `Schema`.
127    Instead, we state that our schema is generic over `MatrixFloat`.
128
129    ```python
130    from typing import Generic, Union
131    from vi.config import dataclass
132
133    @dataclass
134    class CovidSchema(Generic[MatrixFloat]):
135        infectability: Union[float, MatrixFloat] = 1.0
136    ```
137
138    By making the type of `infectability` a union of `float` and `MatrixFloat`,
139    we state that `infectability` will either be a `float` or a `MatrixFloat`.
140    But if you've been playing close attention,
141    you'll notice that our `MatrixFloat` itself can either be a `float` or a `list[float]`.
142    This little trick allows us to state that `infectability` can always be a `float`.
143    But if `MatrixFloat` is a `list[float]`, then `infectability` can be a `list[float]` as well.
144
145    Now, to create our own `ConfigConfig` and `ConfigMatrix`, we inherit `Config` and `Matrix` respectively.
146    However, we also inherit our `CovidSchema` which we just created.
147
148    ```python
149    from vi.config import Config, Matrix
150
151    @dataclass
152    class CovidConfig(Config, CovidSchema[float]):
153        ...
154
155    @dataclass
156    class CovidMatrix(Matrix, CovidSchema[list[float]]):
157        ...
158    ```
159
160    The classes themselves don't add any values.
161    Instead, we simply state that these classes combine `Config`/`Matrix` with their respective `CovidSchema`.
162    And here's where our generic `MatrixFloat` type parameter makes a return.
163    When inheriting `CovidSchema`, we state what the type of `MatrixFloat` must be.
164    By passing a `float` in `CovidConfig`, we ensure that the config will always have only one value for `infectability`.
165    Similarly, passing `list[float]` in `CovidMatrix` allows us to either supply a single value in the matrix,
166    or a list of values for `infectability`.
167
168    Sometimes, it does not make sense to make a value matrix-configurable.
169    In those cases, you do not have to create a `MatrixFloat`-like type parameter.
170    Instead, you can simply use a normal type annotation.
171
172    ```python
173    @dataclass
174    class CovidSchema:
175        infectability: float = 1.0
176    ```
177
178    Note that we still create a `Schema`, as you should never inherit and add values to `Matrix` directly.
179    Instead, you should always create a `Schema` and derive the config and matrix classes.
180
181    If you want to support both matrix floats as well as integers,
182    you can simply make your `Schema` generic over multiple type parameters.
183
184    ```python
185    MatrixFloat = TypeVar("MatrixFloat", float, list[float])
186    MatrixInt = TypeVar("MatrixInt", int, list[int])
187
188    @dataclass
189    class CovidSchema(Generic[MatrixFloat, MatrixInt]):
190        infectability: Union[float, MatrixFloat] = 1.0
191        recovery_time: Union[int, MatrixInt] = 60
192    ```
193
194    Just make sure to use the same order when deriving the `CovidConfig` and `CovidMatrix` classes.
195
196    ```python
197    @dataclass
198    class CovidConfig(Config, CovidSchema[float, int]):
199        #                                         👆
200
201    @dataclass
202    class CovidMatrix(Matrix, CovidSchema[list[float], list[int]]):
203        #            MatrixInt is on the second position too 👆
204    ```
205
206    """
207
208    id: int = 0
209    """The identifier of the config."""
210
211    duration: int = 0
212    """The duration of the simulation in frames.
213
214    Defaults to `0`, indicating that the simulation runs indefinitely.
215    """
216
217    fps_limit: int = 60
218    """Limit the number of frames-per-second.
219
220    Defaults to 60 fps, equal to most screens' refresh rates.
221
222    Set to `0` to uncap the framerate.
223    """
224
225    image_rotation: bool = False
226    """Opt-in image rotation support.
227
228    Please be aware that the rotation of images degrades performance by ~15%
229    and currently causes a bug where agents clip into obstacles.
230    """
231
232    movement_speed: float | Float = 0.5
233    """The per-frame movement speed of the agents."""
234
235    print_fps: bool = False
236    """Print the current number of frames-per-second in the terminal"""
237
238    radius: int | Int = 25
239    """The radius (in pixels) in which agents are considered to be in proximity."""
240
241    seed: int | Int | None = None
242    """The PRNG seed to use for the simulation.
243
244    Defaults to `None`, indicating that no seed is used.
245    """
246
247    visualise_chunks: bool = False
248    """Draw the borders of the proximity-chunks on screen."""
249
250    window: Window = field(default_factory=Window)
251    """The simulation window"""
252
253    def to_configs[T: Config](
254        self,
255        target: type[T],
256    ) -> list[T]:
257        """Generate a config for every unique combination of values in the matrix."""
258        return [target(**values) for values in _matrixify(self.__dict__)]

All values shared between Config and Matrix.

NOTE: DOCUMENTATION OF SCHEMA IS INCORRECT AND WILL BE UPDATED IN VERSION 0.3.1. IF YOU ARE SEEING THIS MESSAGE, MAKE SURE TO UPDATE VIOLET.

A sprinkle of ✨ magical typing ✨ makes list values in the Matrix class possible without any overrides. You'll notice that the Schema class is generic over two type parameters: Int and Float. These type parameters can either be a single int/float value or a list of int/float values respectively. In combination with the Union of int/float, both a Matrix and Config class can be derived.

Examples

To build your own Matrix, you first want to create a Schema with the configuration options that you want to add. For this example, let's say that we want to add an infectability option to our custom Schema. We want this infectability to be a float. However, in our Matrix, we want the possibility to pass multiple floats to automatically generate unique config combinations.

As we want our infectability to be a float or a list[float] depending on whether we have a Config or Matrix respectively, we need to create a type parameter which will act as our placeholder.

from typing import TypeVar
MatrixFloat = TypeVar("MatrixFloat", float, list[float])

Next up, we can create our custom Schema. Note that we do not inherit Schema. Instead, we state that our schema is generic over MatrixFloat.

from typing import Generic, Union
from vi.config import dataclass

@dataclass
class CovidSchema(Generic[MatrixFloat]):
    infectability: Union[float, MatrixFloat] = 1.0

By making the type of infectability a union of float and MatrixFloat, we state that infectability will either be a float or a MatrixFloat. But if you've been playing close attention, you'll notice that our MatrixFloat itself can either be a float or a list[float]. This little trick allows us to state that infectability can always be a float. But if MatrixFloat is a list[float], then infectability can be a list[float] as well.

Now, to create our own ConfigConfig and ConfigMatrix, we inherit Config and Matrix respectively. However, we also inherit our CovidSchema which we just created.

from vi.config import Config, Matrix

@dataclass
class CovidConfig(Config, CovidSchema[float]):
    ...

@dataclass
class CovidMatrix(Matrix, CovidSchema[list[float]]):
    ...

The classes themselves don't add any values. Instead, we simply state that these classes combine Config/Matrix with their respective CovidSchema. And here's where our generic MatrixFloat type parameter makes a return. When inheriting CovidSchema, we state what the type of MatrixFloat must be. By passing a float in CovidConfig, we ensure that the config will always have only one value for infectability. Similarly, passing list[float] in CovidMatrix allows us to either supply a single value in the matrix, or a list of values for infectability.

Sometimes, it does not make sense to make a value matrix-configurable. In those cases, you do not have to create a MatrixFloat-like type parameter. Instead, you can simply use a normal type annotation.

@dataclass
class CovidSchema:
    infectability: float = 1.0

Note that we still create a Schema, as you should never inherit and add values to Matrix directly. Instead, you should always create a Schema and derive the config and matrix classes.

If you want to support both matrix floats as well as integers, you can simply make your Schema generic over multiple type parameters.

MatrixFloat = TypeVar("MatrixFloat", float, list[float])
MatrixInt = TypeVar("MatrixInt", int, list[int])

@dataclass
class CovidSchema(Generic[MatrixFloat, MatrixInt]):
    infectability: Union[float, MatrixFloat] = 1.0
    recovery_time: Union[int, MatrixInt] = 60

Just make sure to use the same order when deriving the CovidConfig and CovidMatrix classes.

@dataclass
class CovidConfig(Config, CovidSchema[float, int]):
    #                                         👆

@dataclass
class CovidMatrix(Matrix, CovidSchema[list[float], list[int]]):
    #            MatrixInt is on the second position too 👆
Schema( id: int = 0, duration: int = 0, fps_limit: int = 60, image_rotation: bool = False, movement_speed: 'float | Float' = 0.5, print_fps: bool = False, radius: 'int | Int' = 25, seed: 'int | Int | None' = None, visualise_chunks: bool = False, window: Window = <factory>)
id: int = 0

The identifier of the config.

duration: int = 0

The duration of the simulation in frames.

Defaults to 0, indicating that the simulation runs indefinitely.

fps_limit: int = 60

Limit the number of frames-per-second.

Defaults to 60 fps, equal to most screens' refresh rates.

Set to 0 to uncap the framerate.

image_rotation: bool = False

Opt-in image rotation support.

Please be aware that the rotation of images degrades performance by ~15% and currently causes a bug where agents clip into obstacles.

movement_speed: 'float | Float' = 0.5

The per-frame movement speed of the agents.

print_fps: bool = False

Print the current number of frames-per-second in the terminal

radius: 'int | Int' = 25

The radius (in pixels) in which agents are considered to be in proximity.

seed: 'int | Int | None' = None

The PRNG seed to use for the simulation.

Defaults to None, indicating that no seed is used.

visualise_chunks: bool = False

Draw the borders of the proximity-chunks on screen.

window: Window

The simulation window

def to_configs(self, target: 'type[T]') -> 'list[T]':
253    def to_configs[T: Config](
254        self,
255        target: type[T],
256    ) -> list[T]:
257        """Generate a config for every unique combination of values in the matrix."""
258        return [target(**values) for values in _matrixify(self.__dict__)]

Generate a config for every unique combination of values in the matrix.

@dataclass
class Window:
76@dataclass
77class Window:
78    """Settings related to the simulation window."""
79
80    width: int = 750
81    """The width of the simulation window in pixels."""
82
83    height: int = 750
84    """The height of the simulation window in pixels."""
85
86    @classmethod
87    def square(cls, size: int) -> Self:
88        return cls(width=size, height=size)
89
90    def as_tuple(self) -> tuple[int, int]:
91        return (self.width, self.height)

Settings related to the simulation window.

Window(width: int = 750, height: int = 750)
width: int = 750

The width of the simulation window in pixels.

height: int = 750

The height of the simulation window in pixels.

@classmethod
def square(cls, size: int) -> Self:
86    @classmethod
87    def square(cls, size: int) -> Self:
88        return cls(width=size, height=size)
def as_tuple(self) -> tuple[int, int]:
90    def as_tuple(self) -> tuple[int, int]:
91        return (self.width, self.height)