Skip to content

Config

Module containing configuration classes.

Configuration

Bases: BaseModel

Main configuration class for the default entry point.

Source code in matbii\config\__init__.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
class Configuration(BaseModel, validate_assignment=True):
    """Main configuration class for the default entry point."""

    experiment: ExperimentConfiguration = Field(default_factory=ExperimentConfiguration)
    participant: ParticipantConfiguration = Field(
        default_factory=ParticipantConfiguration
    )
    guidance: GuidanceConfiguration = Field(default_factory=GuidanceConfiguration)
    window: WindowConfiguration = Field(
        default_factory=_default_window_configuration_factory
    )
    eyetracking: EyetrackingConfiguration = Field(
        default_factory=EyetrackingConfiguration
    )
    logging: LoggingConfiguration = Field(default_factory=LoggingConfiguration)
    ui: UIConfiguration = Field(default_factory=UIConfiguration)

    @staticmethod
    def from_file(
        path: str | Path | None, context: dict[str, Any] | None = None
    ) -> "Configuration":
        """Factory that will build `Configuration` from a .json file.

        Args:
            path (str | Path | None): path to config file.
            context (dict[str, Any] | None, optional): additional context (to override file content). Defaults to None.

        Returns:
            Configuration: resulting configuration.
        """
        context = context if context else {}
        if path:
            path = Path(path).expanduser().resolve().as_posix()
            LOGGER.info(f"Using config file: {path}")
            with open(path) as f:
                data = json.load(f)
                data = always_merger.merge(data, context)
                result = Configuration.model_validate(data)
                result.validate_from_context()  # check consistency of nested fields
                return result
        else:
            LOGGER.info("No config file was specified, using default configuration.")
            return Configuration()

    @staticmethod
    def initialise_logging(config: "Configuration") -> "Configuration":
        """Initialises logging for the given run. This will set logging options and set the config logging path which should be used throughout `matbii` to log information that may be relevant for experiment post-analysis. The configuration passed here will also be logged to the `configuration.json` file in the logging path.

        The logging path will be derived: `<config.experiment.id>/<config.participant.id>` if these values are present, otherwise a timestamp will be used to make the logging path unique. If the two ids are given then they are assumed to be unique (they represent a single trial for a participant).

        Args:
            config (Configuration): configuration

        Raises:
            FileExistsError: if the derived logging path already exists.

        Returns:
            Configuration: the configuration (with updated path variables - modified in place)
        """
        # set logging level
        LOGGER.set_level(config.logging.level)

        # set the logger path
        path = Path(config.logging.path).expanduser().resolve()

        # create the full path
        full_path = path
        if config.experiment.id:
            full_path = full_path / config.experiment.id
        if config.participant.id:
            full_path = full_path / config.participant.id

        if path == full_path:
            full_path = full_path / datetime.now().strftime("%Y%m%d%H%M%S%f")[:-3]

        if full_path.exists():
            raise FileExistsError(
                f"Logging path: {path} already exists, perhaps you have miss-specified `experiment.id` or `participant.id`?\n    See <TODO LINK> for details."
            )

        full_path.mkdir(parents=True)
        LOGGER.debug(f"Logging to: {full_path.as_posix()}")

        # log the configuration that is in use
        with open(full_path / "configuration.json", "w") as f:
            f.write(config.model_dump_json(indent=2))

        config.logging.path = full_path.as_posix()
        return config

    def validate_from_context(self):  # noqa
        self.guidance.validate_from_context(self)
        self.eyetracking.validate_from_context(self)

from_file(path, context=None) staticmethod

Factory that will build Configuration from a .json file.

Parameters:

Name Type Description Default
path str | Path | None

path to config file.

required
context dict[str, Any] | None

additional context (to override file content). Defaults to None.

None

Returns:

Name Type Description
Configuration Configuration

resulting configuration.

Source code in matbii\config\__init__.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
@staticmethod
def from_file(
    path: str | Path | None, context: dict[str, Any] | None = None
) -> "Configuration":
    """Factory that will build `Configuration` from a .json file.

    Args:
        path (str | Path | None): path to config file.
        context (dict[str, Any] | None, optional): additional context (to override file content). Defaults to None.

    Returns:
        Configuration: resulting configuration.
    """
    context = context if context else {}
    if path:
        path = Path(path).expanduser().resolve().as_posix()
        LOGGER.info(f"Using config file: {path}")
        with open(path) as f:
            data = json.load(f)
            data = always_merger.merge(data, context)
            result = Configuration.model_validate(data)
            result.validate_from_context()  # check consistency of nested fields
            return result
    else:
        LOGGER.info("No config file was specified, using default configuration.")
        return Configuration()

initialise_logging(config) staticmethod

Initialises logging for the given run. This will set logging options and set the config logging path which should be used throughout matbii to log information that may be relevant for experiment post-analysis. The configuration passed here will also be logged to the configuration.json file in the logging path.

The logging path will be derived: <config.experiment.id>/<config.participant.id> if these values are present, otherwise a timestamp will be used to make the logging path unique. If the two ids are given then they are assumed to be unique (they represent a single trial for a participant).

Parameters:

Name Type Description Default
config Configuration

configuration

required

Raises:

Type Description
FileExistsError

if the derived logging path already exists.

Returns:

Name Type Description
Configuration Configuration

the configuration (with updated path variables - modified in place)

Source code in matbii\config\__init__.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
@staticmethod
def initialise_logging(config: "Configuration") -> "Configuration":
    """Initialises logging for the given run. This will set logging options and set the config logging path which should be used throughout `matbii` to log information that may be relevant for experiment post-analysis. The configuration passed here will also be logged to the `configuration.json` file in the logging path.

    The logging path will be derived: `<config.experiment.id>/<config.participant.id>` if these values are present, otherwise a timestamp will be used to make the logging path unique. If the two ids are given then they are assumed to be unique (they represent a single trial for a participant).

    Args:
        config (Configuration): configuration

    Raises:
        FileExistsError: if the derived logging path already exists.

    Returns:
        Configuration: the configuration (with updated path variables - modified in place)
    """
    # set logging level
    LOGGER.set_level(config.logging.level)

    # set the logger path
    path = Path(config.logging.path).expanduser().resolve()

    # create the full path
    full_path = path
    if config.experiment.id:
        full_path = full_path / config.experiment.id
    if config.participant.id:
        full_path = full_path / config.participant.id

    if path == full_path:
        full_path = full_path / datetime.now().strftime("%Y%m%d%H%M%S%f")[:-3]

    if full_path.exists():
        raise FileExistsError(
            f"Logging path: {path} already exists, perhaps you have miss-specified `experiment.id` or `participant.id`?\n    See <TODO LINK> for details."
        )

    full_path.mkdir(parents=True)
    LOGGER.debug(f"Logging to: {full_path.as_posix()}")

    # log the configuration that is in use
    with open(full_path / "configuration.json", "w") as f:
        f.write(config.model_dump_json(indent=2))

    config.logging.path = full_path.as_posix()
    return config

ExperimentConfiguration

Bases: BaseModel

Configuration relating to the experiment to be run.

Source code in matbii\config\__init__.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
class ExperimentConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to the experiment to be run."""

    id: str | None = Field(
        default=None, description="The unique ID of this experiment."
    )
    path: str = Field(
        # default_factory=lambda: Path("./").resolve().as_posix(),
        default="./",
        description="The full path to the directory containing task configuration files. If this is a relative path it is relative to the current working directory.",
    )
    duration: int = Field(
        default=-1,
        description="The duration (in seconds) of this experiment (the simulation will close after this time), a negative value will leave the simulation running forever.",
    )
    enable_video_recording: bool = Field(
        default=False,
        description="Whether to begin a screen recording of the experiment when the simulation starts, the video will be saved to the logging path when the experiment ends.",
    )
    enable_tasks: list[str] = Field(
        default=["system_monitoring", "resource_management", "tracking"],
        description="Which tasks to enable at the start of the simulation.",
    )
    meta: dict = Field(
        default={},
        description="Any additional meta data you wish to associate with this experiment.",
    )

    @field_validator("id", mode="before")
    @classmethod
    def _validate_id(cls, value: str | None):
        if value is None:
            LOGGER.warning("Configuration option: `experiment.id` was set to None")
        return value

    @field_validator("path", mode="before")
    @classmethod
    def _validate_path(cls, value: str):
        # we don't want to set it here, it should remain relative, just check that it exists!
        experiment_path = Path(value).expanduser().resolve()
        if not experiment_path.exists():
            raise ValueError(
                f"Configuration option `experiment.path` is not valid: `{experiment_path.as_posix()}` does not exist."
            )
        return value

    def validate_from_context(self, context: "Configuration"):  # noqa
        pass

EyetrackingConfiguration

Bases: BaseModel

Configuration relating to eyetracking.

Source code in matbii\config\__init__.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
class EyetrackingConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to eyetracking."""

    SUPPORTED_SDKS: ClassVar[tuple[str]] = ("tobii",)

    uri: str | None = Field(
        default=None,
        description="The eye tracker address (example: `'tet-tcp://172.28.195.1'`). If left unspecified `matbii` will attempt to find an eye tracker. For details on setting up eye tracking, consult the [wiki](https://github.com/dicelab-rhul/matbii/wiki/Eyetracking).",
    )
    sdk: str = Field(
        default="tobii",
        description="The eye tracking SDK to use, current options are: `['tobii']`.",
    )
    enable: bool = Field(default=False, description="Whether eye tracking is enabled.")
    moving_average_n: PositiveInt = Field(
        default=5,
        description="The window size to used to smooth eye tracking coordinates.",
    )
    velocity_threshold: PositiveFloat = Field(
        default=0.5,
        description="The threshold on gaze velocity which will determine saccades/fixations. This is defined in screen space, where the screen coordinates are normalised in the range [0,1]. **IMPORTANT NOTE:** different monitor sizes may require different values, unfortunately this is difficult to standardise without access to data on the gaze angle (which would be monitor size independent).",
    )

    def validate_from_context(self, context: "Configuration"):  # noqa
        pass

    @model_validator(mode="before")
    @classmethod
    def _validate_model(cls, data: dict[str, Any]):
        if "enabled" in data:
            LOGGER.warning(
                "Configuration option: `eyetracking.enabled` is deprecated and will be removed in the future, please use `eyetracking.enable` instead."
            )
            data["enable"] = data["enabled"]
            del data["enabled"]
        return data

    @field_validator("sdk", mode="before")
    @classmethod
    def _validate_sdk(cls, value: str):
        if value not in EyetrackingConfiguration.SUPPORTED_SDKS:
            raise ValueError(
                f"Eyetracker SDK: {value} is not supported, must be one of {EyetrackingConfiguration.SUPPORTED_SDKS}"
            )
        return value

    def new_eyetracking_sensor(self) -> EyetrackerIOSensor | None:
        """Factory method for an eyetracking sensor.

        Returns:
            EyetrackerIOSensor | None: the sensor, created based on this eyetracking configuration.
        """
        if self.enable:
            eyetracker = self.new_eyetracker()
            return EyetrackerIOSensor(
                eyetracker, self.velocity_threshold, self.moving_average_n
            )
        return None

    def new_eyetracker(self) -> EyetrackerBase | None:
        """Factory method for an eyetracker.

        Returns:
            EyetrackerBase | None: the eyetracker created based on this eyetracking configuration.
        """
        if self.enable:
            if self.sdk == "tobii":
                return self._new_tobii_eyetracker()
            else:
                raise ValueError(
                    f"Eyetracker SDK: {self.sdk} is not supported, must be one of {EyetrackingConfiguration.SUPPORTED_SDKS}"
                )
        return None

    def _new_tobii_eyetracker(self) -> EyetrackerBase:
        from icua.extras.eyetracking import tobii  # this may fail!

        return tobii.TobiiEyetracker(uri=self.uri)

new_eyetracker()

Factory method for an eyetracker.

Returns:

Type Description
EyetrackerBase | None

EyetrackerBase | None: the eyetracker created based on this eyetracking configuration.

Source code in matbii\config\__init__.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def new_eyetracker(self) -> EyetrackerBase | None:
    """Factory method for an eyetracker.

    Returns:
        EyetrackerBase | None: the eyetracker created based on this eyetracking configuration.
    """
    if self.enable:
        if self.sdk == "tobii":
            return self._new_tobii_eyetracker()
        else:
            raise ValueError(
                f"Eyetracker SDK: {self.sdk} is not supported, must be one of {EyetrackingConfiguration.SUPPORTED_SDKS}"
            )
    return None

new_eyetracking_sensor()

Factory method for an eyetracking sensor.

Returns:

Type Description
EyetrackerIOSensor | None

EyetrackerIOSensor | None: the sensor, created based on this eyetracking configuration.

Source code in matbii\config\__init__.py
291
292
293
294
295
296
297
298
299
300
301
302
def new_eyetracking_sensor(self) -> EyetrackerIOSensor | None:
    """Factory method for an eyetracking sensor.

    Returns:
        EyetrackerIOSensor | None: the sensor, created based on this eyetracking configuration.
    """
    if self.enable:
        eyetracker = self.new_eyetracker()
        return EyetrackerIOSensor(
            eyetracker, self.velocity_threshold, self.moving_average_n
        )
    return None

GuidanceArrowConfiguration

Bases: BaseModel

Configuration relating to the arrow guidance.

Source code in matbii\config\__init__.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class GuidanceArrowConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to the arrow guidance."""

    enable: bool = Field(
        default=True,
        description="Whether to enable arrow guidance.",
    )
    mode: Literal["gaze", "mouse", "fixed"] = Field(
        default="gaze",
        description="The mode to use for the arrow guidance, `gaze` will use the current gaze position, `mouse` will use the current mouse position, `fixed` will use the fixed offset specified in `arrow_offset`.",
    )
    scale: float = Field(default=1.0, description="The scale of the arrow.")
    fill_color: str = Field(default="none", description="The fill colour of the arrow.")
    stroke_color: str = Field(
        default="#ff0000", description="The line colour of the arrow outline."
    )
    stroke_width: float = Field(
        default=4.0, description="The line width of the arrow outline."
    )
    offset: tuple[float, float] = Field(
        default=(80, 80),
        description='The offset of the arrow from its set position, this is the position of the arrow if in "fixed" mode.',
    )

    def validate_from_context(self, context: "Configuration"):  # noqa
        if not context.eyetracking.enable and self.mode == "gaze":
            raise ValueError(
                '`guidance.arrow.mode`: "gaze" is not supported when eyetracking is disabled.'
            )

    def to_actuator(self) -> ArrowGuidanceActuator:
        """Factory method for a guidance arrow actuator."""
        return ArrowGuidanceActuator(
            arrow_mode=self.mode,
            arrow_scale=self.scale,
            arrow_fill_color=self.fill_color,
            arrow_stroke_color=self.stroke_color,
            arrow_stroke_width=self.stroke_width,
            arrow_offset=self.offset,
        )

to_actuator()

Factory method for a guidance arrow actuator.

Source code in matbii\config\__init__.py
53
54
55
56
57
58
59
60
61
62
def to_actuator(self) -> ArrowGuidanceActuator:
    """Factory method for a guidance arrow actuator."""
    return ArrowGuidanceActuator(
        arrow_mode=self.mode,
        arrow_scale=self.scale,
        arrow_fill_color=self.fill_color,
        arrow_stroke_color=self.stroke_color,
        arrow_stroke_width=self.stroke_width,
        arrow_offset=self.offset,
    )

GuidanceBoxConfiguration

Bases: BaseModel

Configuration relating to the guidance box.

Source code in matbii\config\__init__.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class GuidanceBoxConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to the guidance box."""

    enable: bool = Field(
        default=True, description="Whether to enable the guidance box."
    )
    stroke_color: str = Field(
        default="#ff0000", description="The line colour of the box outline."
    )
    stroke_width: float = Field(
        default=4.0, description="The line width of the box outline."
    )

    def validate_from_context(self, context: "Configuration"):  # noqa
        pass

    def to_actuator(self) -> BoxGuidanceActuator:
        """Factory method for a guidance box actuator."""
        return BoxGuidanceActuator(
            box_stroke_color=self.stroke_color,
            box_stroke_width=self.stroke_width,
        )

to_actuator()

Factory method for a guidance box actuator.

Source code in matbii\config\__init__.py
81
82
83
84
85
86
def to_actuator(self) -> BoxGuidanceActuator:
    """Factory method for a guidance box actuator."""
    return BoxGuidanceActuator(
        box_stroke_color=self.stroke_color,
        box_stroke_width=self.stroke_width,
    )

GuidanceConfiguration

Bases: BaseModel

Configuration relating to guidance that may be provided to a user.

Source code in matbii\config\__init__.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class GuidanceConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to guidance that may be provided to a user."""

    enable: bool = Field(
        default=True,
        description="Whether to enable guidance, if this is False then no guidance agent will be created. Only set this False if you do not plan to use AT ALL in your experiments, otherwise set `counter_factual` to True.",
    )
    counter_factual: bool = Field(
        default=False,
        description="Whether to show guidance to the user, if False then guidance agent will be configured NOT to display guidance but will still take actions for logging purposes (if they support this).",
    )
    break_ties: _TYPE_BREAK_TIES = Field(
        default="random",
        description="How to break ties if guidance may be shown on multiple tasks simultaneously.",
    )
    grace_mode: _TYPE_GRACE_MODE = Field(
        default="attention",
        description="Condition for which the `grace_period` is waiting. Options: `'guidance_task'` - time from last guidance a task, `'guidance_any'` - time from last guidance on _any_ task, `'failure'` - time from last failure a task, `'attention'` - time from when the user last attended to a task (according to `attention_mode`).",
    )
    attention_mode: _TYPE_ATTENTION_MODE = Field(
        default="fixation",
        description="Method used to track the attention of the user. Options: `'fixation'` - use eyetracking fixations, `'gaze'` - use eyetracking (fixation & saccade), `'mouse'` - use the mouse position.",
    )
    grace_period: float = Field(
        default=3.0,
        description="The grace period to use (seconds) - how long to wait before guidance is shown to the user, see also `grace_mode`.",
    )
    arrow: GuidanceArrowConfiguration = Field(
        default_factory=GuidanceArrowConfiguration,
        description="Configuration for displaying arrow guidance.",
    )
    box: GuidanceBoxConfiguration = Field(
        default_factory=GuidanceBoxConfiguration,
        description="Configuration for displaying box guidance.",
    )

    @field_validator("break_ties", mode="before")
    @classmethod
    def _validate_break_ties(cls, value: _TYPE_BREAK_TIES):
        if value.lower() not in ["random", "longest"]:
            raise ValueError(
                f"`guidance.break_ties` must be one of: {_TYPE_BREAK_TIES.__args__}"
            )
        return value.lower()

    @field_validator("attention_mode", mode="before")
    @classmethod
    def _validate_attention_mode(cls, value: _TYPE_ATTENTION_MODE):
        if value.lower() not in _TYPE_ATTENTION_MODE.__args__:
            raise ValueError(
                f"`guidance.attention_mode` must be one of: {_TYPE_ATTENTION_MODE.__args__}"
            )
        return value.lower()

    @field_validator("grace_mode", mode="before")
    @classmethod
    def _validate_grace_mode(cls, value: _TYPE_GRACE_MODE):
        if value.lower() not in _TYPE_GRACE_MODE.__args__:
            raise ValueError(
                f"`guidance.grace_mode` must be one of: {_TYPE_GRACE_MODE.__args__}"
            )
        return value.lower()

    @model_validator(mode="before")
    @classmethod
    def _validate(cls, data: dict[str, Any]):
        if data.get("counter_factual", False) and not data.get("enable", True):
            raise ValueError(
                "`guidance.counter_factual` cannot be True when `guidance.enable` is False. `guidance.enabled = False` should only be used when guidance is not going to be AT ALL in your experiments or analysis as it will completely disable this feature, otherwise, use `guidance.counter_factual = True`."
            )
        return data

    def validate_from_context(self, context: "Configuration"):  # noqa
        if self.enable:
            self.box.validate_from_context(context)
            self.arrow.validate_from_context(context)
            if (
                self.attention_mode in ["gaze", "fixation"]
                and not context.eyetracking.enable
            ):
                raise ValueError(
                    f'`guidance.attention_mode`: "{self.attention_mode}" is not supported when eyetracking is disabled.'
                )

LoggingConfiguration

Bases: BaseModel

Configuration relating to logging (including event logging).

Source code in matbii\config\__init__.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
class LoggingConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to logging (including event logging)."""

    level: str = Field(
        default="INFO",
        description="The logging level to use: ['DEBUG', 'INFO', 'WARNING', 'ERROR'], this will not affect event logging.",
    )
    path: str = Field(
        default="./logs/",
        description="The path to the directory where log files will be written.",
    )

    @field_validator("level", mode="before")
    @classmethod
    def _validate_level(cls, value: str):
        _value = value.upper()
        if _value not in ["DEBUG", "INFO", "WARNING", "ERROR"]:
            raise ValueError(
                f"Configuration option `logging.level` is invalid: `{value}` must be one of: ['DEBUG', 'INFO', 'WARNING', 'ERROR']"
            )
        return _value

ParticipantConfiguration

Bases: BaseModel

Configuration relating to the participant (or user).

Source code in matbii\config\__init__.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
class ParticipantConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to the participant (or user)."""

    id: str | None = Field(
        default=None,
        description="The unique ID of the participant that is taking part in the experiment.",
    )
    meta: dict = Field(
        default={},
        description="Any additional meta data you wish to associate with the participant.",
    )

    def validate_from_context(self, context: "Configuration"):  # noqa
        pass

UIConfiguration

Bases: BaseModel

Configuration relating to rendering and the UI.

Source code in matbii\config\__init__.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
class UIConfiguration(BaseModel, validate_assignment=True):
    """Configuration relating to rendering and the UI."""

    width: PositiveInt = Field(
        default=810,
        description="The width of the canvas used to render the tasks.",
    )
    height: PositiveInt = Field(
        default=680,
        description="The height of the canvas used to render the tasks.",
    )
    # size: tuple[PositiveInt, PositiveInt] = Field(
    #     default=(810, 680),
    #     description="The width and height of the canvas used to render the tasks. This should fully encapsulate all task elements. If a task appears to be off screen, try increasing this value.",
    # )
    # offset: tuple[NonNegativeInt, NonNegativeInt] = Field(
    #     default=(0, 0),
    #     description="The x and y offset used when rendering the root UI element, can be used to pad the top/left of the window.",
    # )

    @model_validator(mode="before")
    @classmethod
    def _validate_model(cls, data: dict[str, Any]):
        if "size" in data:
            LOGGER.warning(
                "Configuration option: `ui.size` is deprecated and will be removed in the future, please use `ui.width` and `ui.height` instead."
            )
            data["width"] = data["size"][0]
            data["height"] = data["size"][1]
            del data["size"]
        if "offset" in data:
            LOGGER.warning(
                "Configuration option: `ui.offset` is deprecated and has been removed as an option, please instead modify the coordinates of each task to reflect the desired offset."
            )
            del data["offset"]
        return data