Source code for modelly.studio.params

"""The parameter panel's fields, read from a model's ``Scenario`` and constructor.

Every field of the Scenario class the model's ``scenario`` argument defaults to, and every
keyword its constructor takes with a default, becomes a ``Parameter``. Its kind picks the
widget: ``bool`` a checkbox, a ``Literal`` or an ``Enum`` a select, a float between 0 and 1
a slider, another number a number field, a string a text field. Anything else (a tuple, a
dict, None) is shown but cannot be changed from the panel.
"""

from __future__ import annotations

import enum
import inspect
import typing
from dataclasses import dataclass
from typing import Any

from modelly.core.model import Model
from modelly.profile.loader import scenario_class

BOOL, INT, FLOAT, FRACTION, CHOICE, TEXT, FIXED = (
    "bool",
    "int",
    "float",
    "fraction",
    "choice",
    "text",
    "fixed",
)
EDITABLE = frozenset({BOOL, INT, FLOAT, FRACTION, CHOICE, TEXT})
# Constructor arguments that are not model parameters.
NOT_PARAMETERS = frozenset({"self", "scenario", "rng", "seed"})
FRACTION_STEP = 0.01


[docs] @dataclass(frozen=True) class Parameter: """One field of the parameter panel. Attributes: name: The scenario field or constructor keyword. kind: The widget: one of ``bool``, ``int``, ``float``, ``fraction`` (a 0 to 1 slider), ``choice``, ``text`` or ``fixed`` (shown, not editable). default: The value the model is built with unless it is changed. choices: The values a ``choice`` offers. """ name: str kind: str default: Any choices: tuple = () @property def editable(self) -> bool: """Whether the panel lets the value be changed.""" return self.kind in EDITABLE
def _choices(annotation: Any) -> tuple: """The values a ``Literal`` or ``Enum`` annotation allows, else ``()``.""" if typing.get_origin(annotation) is typing.Literal: return typing.get_args(annotation) if inspect.isclass(annotation) and issubclass(annotation, enum.Enum): return tuple(annotation) return ()
[docs] def kind_of(value: Any, annotation: Any = None) -> tuple[str, tuple]: """The widget kind for ``value`` and, for a ``choice``, the values it offers.""" choices = _choices(annotation) if choices: return CHOICE, choices if isinstance(value, bool): return BOOL, () if isinstance(value, int): return INT, () if isinstance(value, float): return (FRACTION if 0.0 <= value <= 1.0 else FLOAT), () if isinstance(value, str): return TEXT, () return FIXED, ()
def _annotations(owner: type) -> dict[str, Any]: """The resolved annotations of ``owner``, or the raw ones when they do not resolve.""" try: return typing.get_type_hints(owner) except (NameError, TypeError): return dict(getattr(owner, "__annotations__", {})) def _scenario_parameters(model_class: type[Model]) -> list[Parameter]: """One parameter per field of the model's Scenario class.""" parameters = dict(inspect.signature(model_class).parameters) scenario = scenario_class(parameters) if scenario is None: return [] hints = _annotations(scenario) found = [] for name, default in scenario._scenario_defaults.items(): if name in NOT_PARAMETERS: # the seed has its own field continue kind, choices = kind_of(default, hints.get(name)) found.append(Parameter(name, kind, default, choices)) return found def _constructor_parameters(model_class: type[Model]) -> list[Parameter]: """One parameter per keyword with a default the constructor takes.""" found = [] for name, param in inspect.signature(model_class).parameters.items(): if name in NOT_PARAMETERS or param.default is inspect.Parameter.empty: continue kind, choices = kind_of(param.default, param.annotation) found.append(Parameter(name, kind, param.default, choices)) return found
[docs] def parameters_of(model_class: type[Model]) -> list[Parameter]: """The panel's fields for ``model_class``: its constructor's, then its Scenario's.""" own = _constructor_parameters(model_class) names = {p.name for p in own} return own + [p for p in _scenario_parameters(model_class) if p.name not in names]
__all__ = ["Parameter", "kind_of", "parameters_of"]