Source code for energia.represent.model

"""A Model"""

from __future__ import annotations

import logging
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Self, Type

from dill import dump

from .._core._x import _X
from ..components.commodities.currency import Currency
from ..components.commodities.emission import Emission
from ..components.commodities.land import Land
from ..components.commodities.material import Material
from ..components.commodities.resource import Resource
from ..components.game.couple import Interact
from ..components.game.player import Player
# from ..components.graph.edge import Edge
# from ..components.graph.node import Node
from ..components.impact.categories import Economic, Environ, Social
from ..components.measure.unit import Unit
from ..components.operations.process import Process
from ..components.operations.storage import Storage
from ..components.operations.transport import Transport
from ..components.spatial.linkage import Linkage
from ..components.spatial.location import Location
from ..components.temporal.lag import Lag
from ..components.temporal.modes import Modes
from ..components.temporal.periods import Periods
from ..components.temporal.scales import TemporalScales
from ..dimensions.game import Game
from ..dimensions.impact import Impact
from ..dimensions.problem import Problem
from ..dimensions.space import Space
from ..dimensions.system import System
from ..dimensions.time import Time
from ..library.aliases import aspect_aliases
from ..library.instructions import costing_commodity, costing_operation
from ..library.recipes import (capacity_sizing, economic, environmental,
                               free_movement, inventory_sizing, operating,
                               social, trade, usage)
from ..modeling.parameters.instruction import Instruction
from ..modeling.variables.control import Control
from ..modeling.variables.recipe import Recipe
from ..modeling.variables.states import Consequence, State, Stream
from .ations.graph import Graph
from .ations.program import Program
from .ations.scenario import Scenario

logger = logging.getLogger("energia")
logger.setLevel(logging.INFO)

ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
formatter = logging.Formatter("%(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)


if TYPE_CHECKING:
    from enum import Enum
    from typing import DefaultDict

    from .._core._component import _Component
    from ..components.commodities.commodity import Commodity
    from ..modeling.indices.domain import Domain
    from ..modeling.indices.sample import Sample
    from ..modeling.variables.aspect import Aspect

    BalanceType = DefaultDict[
        Commodity,
        DefaultDict[Location | Linkage, DefaultDict[Periods | Lag, list[Aspect]]],
    ]


[docs] @dataclass class Model: """ An abstract representation of an energy system. :param name: Name of the Model. Defaults to m. :type name: str :param init: List of functions to initialize the Model. Defaults to None. :type init: list[Callable], optional :param default: True if some default objects should be declared. :type default: bool :param capacitate: True if process capacities should be determined to bound operations. :type capacitate: bool :ivar added: List of added objects to the Model. :vartype added: list[str] :ivar update_map: maps component type to representation and collection. :vartype update_map: dict :ivar time: time representation of the Model. :vartype time: Time :ivar space: spatial representation of the Model. :vartype space: Space :ivar impact: impact representation of the Model. :vartype impact: Impact :ivar tree: feasible region (Decision-Making) of the Model. :vartype tree: Tree :ivar graph: Graph (Network) of the Model. :vartype graph: Graph :ivar system: System (Resource Task Network) of the Model. :vartype system: System :ivar program: Mathematical (mixed integer) program of the Model. :vartype program: Program :ivar conversions: List of Balances in the Model. :vartype conversions: list[Conversion] :ivar convmatrix: Conversion matrix of the Model. :vartype convmatrix: dict[Process, dict[Resource, int | float | list]] :ivar modes_dict: Dictionary mapping Bind objects to Modes. :vartype modes_dict: dict[Bind, Modes] :siunits_set: True if SI units have been set. :vartype siunits_set: bool :ivar cookbook: Recipes to create Aspects. :vartype cookbook: dict[str, Recipe] :ivar directory: Map of attribute names to recipes for creating them. :vartype directory: dict[str, dict[str, Recipe]] :ivar classifiers: List of classifiers for the Model. :vartype classifiers: list[Enum] :ivar grb: Dictionary which tells you what aspects of resource have GRB {loc: time: []} and {time: loc: []}. :vartype grb: DefaultDict[Commodity,DefaultDict[Location | Linkage, DefaultDict[Periods, list[Aspect]]]] :ivar dispositions: Dictionary which tells you what aspects of what component have been bound at what location and time. :vartype dispositions: dict[Aspect, dict[Commodity | Process | Storage | Transport, dict[Location | Linkage, dict[Periods, list[Aspect]]]]] :ivar maps: Maps of aspects to domains. :vartype maps: dict[Aspect, dict[Domain, dict[str, list[Domain]]]] :ivar maps_report: Maps of aspects to domains for reporting variables. :vartype maps_report: dict[Aspect, dict[Domain, dict[str, list[Domain]]]] :raises ValueError: If an attribute name already exists in the Model. """ name: str = "m" init: list[Callable[[Self]]] | None = None default: bool = True capacitate: bool = False def __post_init__(self): self.reserved_names = [] # what components have been added to the model self.added: list[str] = [] # map of what representation and collection within that representation # an object of a particular type belongs to # -------------------------------------------------------------------- # * Component Mapping to Dimension and Collection # -------------------------------------------------------------------- # Dimensions in brackets self.familytree = { # * I Temporal (Time): # 1. Periods (Periods) generates a bespoke discretization. Periods: ("time", "periods"), # 2. Modes discrete options in the same time Modes: ("time", "modes"), # * II Spatial (Space): # Spatial representation (Space). # 1. a bespoke discretization. Location: ("space", "locations"), # 2. link between them Linkage: ("space", "linkages"), # * III Streams (System): # All are Commodity derived: # 1. Money (Currency) Currency: ("system", "currencies"), # 2. Land (Land) resource Land: ("system", "lands"), # 3. Emission (Emission) resource Emission: ("system", "emissions"), # Resource is a general Commodity # These are Resource subsets # 1. Material (Material) used to setup processes Material: ("system", "materials"), # 2. etc. societal (Jobs), etc (Etc). Resource: ("system", "resources"), # * IV Operations (System): # 1. A production operation (Process) involves conversion of resources Process: ("system", "processes"), # 2. A transport operation (Transport) which describes a task in the system # that involves transporting resources from # one location to another. Transport: ("system", "transports"), # 3. A storage operation (Storage) stores (charges) # and retrieves (discharge) resources Storage: ("system", "storages"), # *V Indicators (Consequence): # scales a stream and projects onto a common metric # categories include Environ: ("impact", "environment"), Social: ("impact", "society"), Economic: ("impact", "economy"), # * VI Game Components # To model Competition Player: ("game", "players"), Interact: ("game", "interacts"), # * VII Problem Aspects # The problem at hand # 1. to control the volume of streams Control: ("problem", "controls"), # 2. movement Stream: ("problem", "streams"), # 3. size, quantity State: ("problem", "states"), # 4. consequence Consequence: ("problem", "consequences"), } self.reserved_names += zip(*self.familytree.values()) # -------------------------------------------------------------------- # * Dimensions or Representation # -------------------------------------------------------------------- # * I Dimensions # * 1. Time with Periods and Modes self.time = Time(self) # * 2. Space with Locations and Linkages self.space = Space(self) # * 3. Impact with Indicator categories self.impact = Impact(self) # * 4. System (Resource Task Network) self.system = System(self) # * II Representations # * 1. Graph with Edges and Nodes self.graphs = [Graph(self)] # * 2. Problem at hand self.problems = [Problem(self)] # * 3 Mathematical Program of mpMINLP subclass self.programs = [Program(model=self)] # * 4 Scenario, the parameter set or uncertainty realization self.scenarios = [Scenario(model=self)] # * 5 Game, the decision-making representation self.games = [Game(model=self)] # shorthand self._ = self.program # -------------------------------------------------------------------- # * Attributes Inherited from Dimensions or Representations # -------------------------------------------------------------------- # Start with patterened self.program_attrs = [ "constraint", "function", "variable", "parameter", "theta", ] # word -> words and word_sets self.program_attrs += [ w + s for w in self.program_attrs for s in ['s', '_sets'] ] self.program_attrs += [ "solution", "solutions", "formulation", "formulations", "evaluation", ] # word -> n_word self.program_attrs += ['n_' + w for w in self.program_attrs] self.program_attrs += [ "index_sets", "indices", "objectives", "parameter_sets", "X", ] self.reserved_names += self.program_attrs # properties that can be called by model # these never get set _program_matrices = [ "A", "B", "C", "F", "G", "H", "CrA", "CrB", "NN", "A_with_NN", "B_with_NN", "Z", "P", ] self.reserved_names += _program_matrices self.properties = {i: self.program for i in _program_matrices} # -------------------------------------------------------------------- # * Default Components # -------------------------------------------------------------------- # if any of these attributes are called, # or an exiting one is returned self.default_components = { "l": self._l0, "l0": self._l0, "t0": self._t0, "t": self._t0, "money": self._cash, } self.graph_components = ["edges", "nodes"] # -------------------------------------------------------------------- # * Books of Maps Between: # -------------------------------------------------------------------- # * matching_aspect -> Recipe self.cookbook: dict[str, Recipe] = {} # * parameter_name -> parameter_handling_instruction self.manual: dict[str, Instruction] = {} # * already_defined_user_input_attr -> matching_aspect self.registry: dict[str, Aspect] = {} # * user_input_attr -> matching_aspect -> Recipe self.directory: dict[str, dict[str, Recipe]] = {} # * collection -> dimension # derived from familytree self.ancestry = { collection: dimension for dimension, collection in self.familytree.values() } # * collection to component # derived from familytree self.ilk = { collection: component for component, (_, collection) in self.familytree.items() } # -------------------------------------------------------------------- # * Constraint Ledger # -------------------------------------------------------------------- # Dictionary which tells you what aspects of resource # have been set in what location and time # * General Resource Balances self.balances: BalanceType = defaultdict( lambda: defaultdict(lambda: defaultdict(list)) ) # Dictionary which tells you what aspects of what component # have been bound at what location and time # * Sample Dispositions self.dispositions: dict[ Aspect, dict[ Commodity | Process | Storage | Transport, dict[Location | Linkage, dict[Periods, list[Aspect]]], ], ] = {} # * Drawn Maps self.maps: dict[Aspect, dict[str, dict[Domain, list[Domain]]]] = {} self.maps_report: dict[Aspect, dict[str, dict[Domain, list[Domain]]]] = {} # * Generated Modes self.modes_dict: dict[Sample, Modes] = {} # * Conversion Matrix self.convmatrix: dict[Process, dict[Resource, int | float | list]] = {} # -------------------------------------------------------------------- # * Measurement Related # -------------------------------------------------------------------- self.units: list[Unit] = [] # if SI units have been set self.siunits_set: bool = False # -------------------------------------------------------------------- # * Model Classification # -------------------------------------------------------------------- self.classifiers: dict[str, list[Enum]] = { "uncertainty": [], "structure": [], "scale": [], "paradigm": [], } # -------------------------------------------------------------------- # * Model Initialization # -------------------------------------------------------------------- # functions are passed and initialized on self if not self.init: self.init = [] if self.default: self.init += [ # Recipes capacity_sizing, operating, inventory_sizing, free_movement, trade, economic, environmental, social, usage, aspect_aliases, # Instructions costing_operation, costing_commodity, ] for func in self.init: func(self) # ------------------------------------------------------------------- # * Active Representation # ------------------------------------------------------------------- @property def problem(self) -> Problem: """The active problem""" return self.problems[-1] @property def graph(self) -> Graph: """The active graph""" return self.graphs[-1] @property def program(self) -> Program: """The active program""" return self.programs[-1] @property def scenario(self) -> Scenario: """The active scenario""" return self.scenarios[-1] @property def game(self) -> Game: """The active game""" return self.games[-1] # ------------------------------------------------------------------- # * Dimensional Properties and Collections # ------------------------------------------------------------------- @property def horizon(self) -> Periods: """Time horizon""" return self.time.horizon @property def network(self) -> Location: """Encompassing Location""" return self.space.network @property def indicators(self) -> list[Environ | Social | Economic]: """Impact indicators""" return self.impact.indicators @property def operations(self) -> list[Process | Storage | Transport]: """System operations""" return self.system.operations @property def aspects(self) -> list[Consequence | Stream | Control | State]: """Problem aspects""" return self.problem.aspects @property def domains(self) -> list[Domain]: """Problem domains""" return self.problem.domains # ------------------------------------------------------------------- # * Onboard Component and Send to Family # -------------------------------------------------------------------
[docs] def update( self, name: str, value: _X, represent: str, collection: str, aspects: list[str] | None = None, ): """Update the Model with a new value :param name: Name of the value to be added :type name: str :param value: Value to be added :type value: X :param represent: Representation to which the value belongs :type represent: str :param collection: Collection within the representation to which the value belongs :type collection: str :param aspects: Aspects to be added to the value, defaults to None :type aspects: list[str], optional """ value.name = name # every component is handed the model value.model = self if name in self.added: # do not allow overriding of components # throw error if name already exists raise AttributeError(f"{name} already defined") # added is the list of all components that have been added to the model self.added.append(name) # if not subset: # # ignore subsets model_set: list = getattr(getattr(self, represent), collection) # the set that needs to be updated model_set.append(value) # update the index set for index elements if collection in [ "resources", "currencies", "lands", "emissions", "materials", "processes", "storages", "transits", "locations", "linkages", ]: setattr( self.program, collection, getattr(self.program, collection) | value.I )
# # set aspect samples on the components # if aspects: # for asp in aspects: # aspect = getattr(self, asp) # setattr(value, asp, aspect(value)) # if aspect.neg is not None: # setattr(value, aspect.neg.name, aspect.neg(value)) # ------------------------------------------------------------------- # * Birthing Procedures and Setting Aliases # ------------------------------------------------------------------- # These take an action on attribute inputs
[docs] def Recipe( self, name: str, kind: Type[Aspect], primary_type: tuple[Type[_Component]] | Type[_Component], label: str = "", latex: str = "", add: str = "", add_latex: str = "", add_kind: Type[Aspect] | None = None, sub: str = "", sub_latex: str = "", sub_kind: Type[Aspect] | None = None, neg: str = "", neg_latex: str = "", neg_label: str = "", bound: str = "", ispos: bool = True, nn: bool = True, use_multiplier: bool = False, ): """Creates a Recipe and updates recipes :param name: Name of the aspect :type name: str :param kind: type of the aspect :type kind: Type[Aspect] :param primary_type: type of primary component :type primary_type: tuple[Type[_Component]] | Type[_Component] :param label: label for the aspect. Defaults to ''. :type label: str, optional :param latex: LaTeX representation for the aspect. Defaults to None. :type latex: str, optional :param add: add control variable. Defaults to ''. :type add: str, optional :param add_latex: LaTeX representation for the add aspect. Defaults to ''. :type add_latex: str, optional :param add_kind: type of the add aspect. Defaults to None. :type add_kind: Type[Aspect], optional :param sub: sub control variable. Defaults to ''. :type sub: str, optional :param sub_latex: LaTeX representation for the sub aspect. Defaults to ''. :type sub_latex: str, optional :param sub_kind: type of the sub aspect. Defaults to None. :type sub_kind: Type[Aspect], optional :param neg: name of the negative aspect. Defaults to ''. :type neg: str, optional :param neg_latex: LaTeX representation for the negative aspect. Defaults to ''. :type neg_latex: str, optional :param neg_label: label for the negative aspect. Defaults to ''. :type neg_label: str, optional :param bound: name of the bound aspect. Defaults to ''. :type bound: str, optional :param ispos: whether the aspect is positive. Defaults to True. :type ispos: bool, optional :param nn: whether the aspect is non-negative. Defaults to True. :type nn: bool, optional :param use_multiplier: Use a scaler (such as distance) for calculations :type use_multiplier: bool """ if name in self.cookbook: logger.warning("⛔ Overriding existing recipe: %s ⛔", name) self.cookbook[name] = Recipe( name=name, kind=kind, label=label or name, add=add, sub=sub, bound=bound, ispos=ispos, nn=nn, latex=latex, use_multiplier=use_multiplier, primary_type=primary_type, ) if add: self.Recipe( name=add, kind=add_kind or sub_kind or Control, label=add_latex or add, ispos=True, nn=True, latex=latex or add, primary_type=primary_type, ) if sub: self.Recipe( name=sub, kind=sub_kind or add_kind or Control, label=sub_latex or sub, ispos=False, nn=True, latex=latex or sub, primary_type=primary_type, ) if neg: neg_recipe = Recipe( name=neg, kind=kind, label=neg_label, ispos=not ispos, nn=nn, latex=neg_latex or neg, use_multiplier=use_multiplier, primary_type=primary_type, ) self.cookbook[neg] = neg_recipe
[docs] def alias(self, *names: str, of: str): """Set aspect aliases :param names: Names of the aliases :type names: str :param to: Name of the aspect to which the aliases point :type to: str """ _add = dict.fromkeys(list(names), {of: self.cookbook[of]}) self.directory = {**self.directory, **_add}
[docs] def Instruction( self, name: str, kind: Type[_Component], deciding: str, depending: str, default: str, label: str = "", latex: str = "", ): """Creates an Instruction and updates the manual :param deciding: Name of the deciding aspect :type deciding: str :param depending: Name of the depending aspect :type depending: str :param default: Name of the default component :type default: str :param label: Label for the parameter. Defaults to ''. :type label: str, optional :param latex: LaTeX representation for the parameter. Defaults to ''. :type latex: str, optional """ self.manual[name] = Instruction( name=name, kind=kind, deciding=deciding, depending=depending, default=default, label=label, latex=latex, )
# ------------------------------------------------------------------------ # * Easy Birthing of Components # ------------------------------------------------------------------------
[docs] def declare(self, what: Type[_X], names: list[str]): """Declares objects conveniently :param what: Type of object to be created :type what: Type[X] :param names: Names of the objects to be created :type names: list[str] """ for i in names: setattr(self, i, what())
[docs] def TemporalScales(self, discretizations: list[int], names: list[str]): """ This is an easy way to define multiple time periods (scales) :param discretizations: List of discretizations for the temporal scale. :type discretizations: list[int] :param names: Names of the discretizations. Defaults to [t<i>] for each discretization. :type names: list[str], optional """ # set the root periods setattr(self, names[-1], Periods()) # pick up the period that was just created # use it as the root root = getattr(self, "periods")[-1] discretizations = list(reversed(discretizations)) names = list(reversed(names[:-1])) if discretizations[-1] != 1: discretizations.append(1) names.append("t0") for disc, name in zip(discretizations, names): setattr(self, name, disc * root) root = getattr(self, "periods")[-1]
[docs] def Modes(self, size: int, sample: Sample): """ This is an easy way to define modes within a period :param size: Number of modes to create :type size: int :param name: Name of the modes. Defaults to "modes". :type name: str, optional """ modes = Modes(size=size, sample=sample, n=len(getattr(self, "modes"))) periods = sample.domain.periods or self.time.horizon setattr(self, f'_{periods}{len(periods.modes)}', modes) periods.modes.append(modes) return modes
# ------------------------------------------------------------------------ # * Illustrations from Different Perspectives # ------------------------------------------------------------------------ # * I Mathematical
[docs] def show( self, descriptive: bool = False, categorical: bool = True, category: str = "", ): """ Pretty print the Model :param descriptive: Whether to show descriptive information. Defaults to False. :type descriptive: bool, optional :param categorical: Whether to group by category. Defaults to True. :type categorical: bool, optional :param category: If provided, shows only this category. Defaults to None. :type category: str, optional """ self.program.show(descriptive, categorical=categorical, category=category)
# * II Graphical
[docs] def draw(self, variable: Aspect | Sample | None = None, n_sol: int = 0): """ Draw the solution for a variable :param variable: Variable to draw. Defaults to None. :type variable: Aspect | Sample | None, optional :param n_sol: Solution number to draw. Defaults to 0. :type n_sol: int, optional """ if variable is not None: self.program.draw(variable=variable.V(), n_sol=n_sol) else: self.program.draw(n_sol=n_sol)
# * III Solution
[docs] def output(self, n_sol: int = 0, slack: bool = True, compare: bool = False): """Solution""" return self.program.output(n_sol=n_sol, slack=slack, compare=compare)
# ------------------------------------------------------------------------ # * Solution Prep, Generation, and Handling # ------------------------------------------------------------------------
[docs] def locate(self, *operations: Process | Storage): """Locate operations in the network :param operations: Operations to locate :type operations: Process | Storage """ self.network.locate(*operations)
# * Optimization
[docs] def solve( self, using: Literal[ "combinatorial", "combinatorial_parallel", "combinatorial_parallel_exp", "graph", "graph_exp", "graph_parallel", "graph_parallel_exp", "combinatorial_graph", "geometric", "geometric_parallel", "geometric_parallel_exp", ] = "combinatorial", ): """ Solve the multiparametric program :param using: The solving method to use. Defaults to "combinatorial". :type using: Literal """ self.program.solve(using=using)
# * Solution evaluation
[docs] def eval( self, *theta_vals: float, n_sol: int = 0, roundoff: int = 4 ) -> list[float]: """ Evaluate the objective function at given theta values :param theta_vals: values for the parametric variables :type theta_vals: float :param n_sol: solution number to evaluate, defaults to 0 :type n_sol: int, optional :param roundoff: number of decimal places to round off to, defaults to 4 :type roundoff: int, optional :return: list of objective function values :rtype: list[float] """ return self.program.eval(*theta_vals, n_sol=n_sol, roundoff=roundoff)
# * Saving
[docs] def save(self, as_type: str = "dill"): """Save the Model to a file""" if as_type == "dill": with open(self.name + ".energia", "wb") as f: dump(self.solution, f) else: raise ValueError(f"Unknown type {as_type} for saving the model")
# ------------------------------------------------------------------------ # * Default Components # ------------------------------------------------------------------------ def _t0(self, size: int = 1) -> Periods: """Return a default period :param size: Size of the period. Defaults to 0. :type size: int, optional :return: Periods object :rtype: Periods """ periods = getattr(self, "periods") if not periods: if size > 1: self.t1 = Periods() self.t0 = size * self.t1 return self.t1 if size > 1: setattr(self, f"t{len(periods)}", self.horizon / size) return periods[-1] self.t0 = Periods() return self.t0 def _l0(self) -> Location: """Return a default location""" self.l0 = Location(label="l") return self.l0 def _cash(self) -> Currency: """Return a default currency""" currencies = getattr(self, "currencies") if currencies: return currencies[0] self.cash = Currency(label="$") return self.cash # ------------------------------------------------------------------- # * Attribute Setting and Getting # ------------------------------------------------------------------- def __setattr__(self, name, value): if isinstance(value, (str, dict, list, bool)) or value is None: # if value is a string, dict, list or bool # set the attribute to the value super().__setattr__(name, value) return if isinstance(value, TemporalScales): self.TemporalScales(value.discretizations, value.names) return if isinstance(value, Unit): value.name = name self.units.append(value) # map to representation and collection for cls, updates in self.familytree.items(): if isinstance(value, cls): # for args in updates: self.update(name, value, *updates) break # Special linkage instructions if isinstance(value, Linkage): self.space.sources.append(value.source) self.space.sinks.append(value.sink) if value.bi: # if bidirectional, set the reverse linkage # also ensures that all linakges go in one direction only rev = value.rev() setattr(self, rev.name, rev) super().__setattr__(name, value) def __getattr__(self, name): # Only called when attribute does not exist # if something like t, t0 is called # just return a default component # t/t0, l/l0, cash, money # this will not intefere with the setting of # attributes what this name if name in self.default_components: component = self.default_components[name]() return component # Inherits collections based on ancestry if name in self.ancestry: dimension = getattr(self, self.ancestry[name]) collection = getattr(dimension, name) setattr(self, name, collection) return collection # Program attributes if name in self.program_attrs: collection = getattr(self.program, name) setattr(self, name, collection) return collection # properties from dimensions and representations if name in self.properties: return getattr(self.properties[name], name) # already declare and mapped to aspect if name in self.registry: return self.registry[name] if name in self.manual: return self.manual[name] # Recipe for defining aspects if name in self.cookbook: recipe = self.cookbook[name] aspect = recipe.kind(**recipe.args) setattr(self, name, aspect) return aspect # maps many attribute names to aspects if name in self.directory: # if this is an attribute being called for the first time recipe = self.directory[name] aspect_name = list(recipe.keys())[0] if aspect_name in self.added: # if same aspect is called by a different name return getattr(self, aspect_name) # these are the arguments for the aspect recipe = recipe[aspect_name] aspect = recipe.kind(**recipe.args) setattr(self, aspect_name, aspect) self.registry[name] = aspect return aspect raise AttributeError( f"{self} has no '{name}'", ) # --------------------------------------------------------------- # * Call to Initialize using functions and Hashing # --------------------------------------------------------------- def __call__(self, *funcs: Callable[[Self]]): """Set functions on the model These can include default units """ for f in funcs: f(self) def __str__(self): return self.name def __repr__(self): return self.name def __hash__(self): return hash(self.name)