Coverage for agentlib/core/datamodels.py: 96%
366 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-04-07 16:27 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2025-04-07 16:27 +0000
1"""
2The datamodels module contains all classes
3defining basic models to handle data.
4"""
6import abc
7import functools
8import json
9import logging
10import math
11import numbers
12from copy import copy, deepcopy
13from enum import Enum
14from io import StringIO
15from itertools import chain
16from typing import Union, Any, List, Optional, TypeVar, Set, Container, get_args
18import attrs
19import numpy as np
20import pandas as pd
21from attrs import define, field
22from pydantic import GetCoreSchemaHandler
23from pydantic_core import core_schema
24from pydantic_core.core_schema import CoreSchema, SerializationInfo
26logger = logging.getLogger(__name__)
29ATTRS_MODELS = [
30 "AgentVariables",
31 "AgentVariable",
32 "BaseVariable",
33 "ModelInput",
34 "ModelState",
35 "ModelOutput",
36 "ModelParameter",
37 "ModelInputs",
38 "ModelStates",
39 "ModelOutputs",
40 "ModelParameters",
41 "ModelVariable",
42 "Source",
43]
46__all__ = ATTRS_MODELS + [
47 "Causality",
48 "Variability",
49]
52class Causality(str, Enum):
53 """
54 Enumeration that defines the causality of a variable.
56 The default causality is “local”.
58 Allowed params of his enumeration:
59 """
61 # _init_ = "value __doc__"
62 parameter = "parameter"
63 calculatedParameter = "calculatedParameter"
65 input = "input"
66 output = "output"
67 local = "local"
68 independent = "independent"
71class Variability(str, Enum):
72 """
73 Enumeration that defines the time dependency of the variable, in other
74 words,it defines the time instants when a variable can change its value.
75 [The purpose of this attribute is to define when a result value needs to
76 be inquired and to be stored. For example, discrete variables change
77 their params only at event instants (ModelExchange) or at a
78 communication point (CoSimulation) and it is therefore only necessary
79 to inquire and store them at event times].
81 The default is “continuous”
83 Allowed params of this enumeration:
84 """
86 constant = "constant"
87 fixed = "fixed"
88 tunable = "tunable"
89 discrete = "discrete"
90 continuous = "continuous"
93###############################################################################
94# Custom Field types
95###############################################################################
98class AttrsToPydanticAdaptor(abc.ABC):
99 """
100 Class to use the attrs-based class in pydantic models.
101 """
103 @abc.abstractmethod
104 def dict(self):
105 """Returns the dict object of the class."""
106 raise NotImplementedError
108 @abc.abstractmethod
109 def json(self) -> str:
110 """Returns json serialization of the class"""
111 raise NotImplementedError
113 @classmethod
114 @abc.abstractmethod
115 def create(cls, data: Union[dict, "AttrsToPydanticAdaptor"]):
116 raise NotImplementedError
118 def _serialize(self, _info: SerializationInfo):
119 """Function required for pydantic"""
120 if _info.mode == "python":
121 return self.dict()
122 return self.json()
124 @classmethod
125 def __get_pydantic_core_schema__(
126 cls, source_type: Any, handler: GetCoreSchemaHandler
127 ) -> CoreSchema:
128 """Tells pydantic how to instantiate and validate this class."""
129 return core_schema.no_info_after_validator_function(
130 cls.create, # validator
131 core_schema.any_schema(ref=cls.__name__), # what to call before validator
132 serialization=core_schema.plain_serializer_function_ser_schema(
133 cls._serialize,
134 info_arg=True,
135 return_schema=core_schema.any_schema(),
136 ), # serialization
137 )
139 @classmethod
140 def get_json_schema(cls):
141 """
142 Return the JSON-Schema of the class.
143 Will try to create the schema based on the attrs-fields
144 and existing types. Specially nested types may not work,
145 e.g. Union[Dict[str, AgentVariable], List[Source]] o.s.
146 However, the current Variables and Source do not require
147 such complex type annotations.
148 """
150 def _get_type_schema(_type: object) -> (dict, list):
151 """
152 Function to return the schema for the given _type.
153 The type is the one given in the attr-field.
154 Special types, which are not the _type_map, will not work properly.
155 Avoiding the .get method to explicitly raise the error if
156 future versions of custom AttrsToPydanticAdaptor use such types.
158 Returns the schema as the first argument and a list of references
159 as the second.
160 """
161 # Map to infer the json-schema name for the type
162 # from the python object
163 _type_map = {
164 str: "string",
165 bool: "boolean",
166 int: "integer",
167 float: "number",
168 list: "array",
169 }
170 if issubclass(_type, AttrsToPydanticAdaptor):
171 return {"$ref": f"#/definitions/{_type.__name__}"}, _type.__name__
172 else:
173 return {"type": _type_map[_type]}, []
175 def _get_typing_schema(_type) -> (dict, list):
176 """
177 Recursive to extract the type schema for all possible types
178 which currently occur in the attrs fields. This includes
179 standard types like str and typing-types like Union[str, float].
181 Returns the schema as the first argument and a list of references
182 as the second.
183 """
184 if _type == Any:
185 # Any can't be used by GUIs or OpenAPI anyway. At the
186 # same time, it is the only type in typing with no __origin__.
187 # TODO-ses: We could also return string as the type, as
188 # I had to tweak streamlit-pydantic to render Any as
189 # string. Depends on streamlit-pydantic fork moves forward
190 return {}, []
191 if isinstance(_type, type):
192 return _get_type_schema(_type)
194 # If it's not a type object, it currently is always a typing object,
195 # which indicates the actual type using __origin__.
196 # We could also use `get_origin` from typing.
197 if isinstance(_type.__origin__, type):
198 return _get_type_schema(_type.__origin__)
199 _types = get_args(_type)
200 if type(None) in _types: # Is optional
201 return _get_typing_schema(_types[0]) # 2nd entry will be None
202 if _type.__origin__ == Union:
203 refs = []
204 _any_of_types = []
205 for _type in _types:
206 _any_of_type, _ref = _get_type_schema(_type)
207 refs.append(_ref)
208 _any_of_types.append(_any_of_type)
209 return {"anyOf": _any_of_types}, refs
210 raise TypeError(
211 f"Given type '{_type}' is not supported for JSONSchema export"
212 )
214 field_schemas = {}
215 required = []
216 all_refs = []
217 for attr in attrs.fields(cls):
218 field_schemas[attr.name] = dict(attr.metadata)
219 _type_schema, _refs = _get_typing_schema(attr.type)
220 all_refs.extend(_refs)
221 field_schemas[attr.name].update(_type_schema)
222 # Get default
223 if attr.default:
224 field_schemas[attr.name].update({"default": attr.default})
225 else:
226 required.append(attr.name)
228 schema = {
229 "title": cls.__name__,
230 "description": cls.__doc__,
231 "type": "object",
232 "properties": field_schemas,
233 "required": required,
234 "definitions": [
235 f"$defs/{ref}" for ref in set([r for r in list(all_refs) if r])
236 ],
237 "additionalProperties": False,
238 }
240 return schema
243@define(slots=True, frozen=True)
244class Source(AttrsToPydanticAdaptor):
245 """
246 Object to define the source of a variable or possible
247 other object. As objects are passed both module-internally
248 by agents or across multiple agents, both the
249 agent_id and module_id build up the source object.
251 However, with methods like 'matches', one can indicate
252 setting any id to None that the id is irrelevant.
253 """
255 agent_id: Optional[str] = None
256 module_id: Optional[str] = None
258 def __str__(self):
259 return f"{self.agent_id}_{self.module_id}"
261 def __hash__(self):
262 return hash(str(self))
264 def __eq__(self, other):
265 if isinstance(other, dict):
266 return self.agent_id == other.get(
267 "agent_id"
268 ) and self.module_id == other.get("module_id")
269 if isinstance(other, Source):
270 return self.agent_id == other.agent_id and self.module_id == other.module_id
271 return False
273 def dict(self):
274 """Overwrite pydantic method to be faster."""
275 # pylint: disable=unused-argument
276 return {"agent_id": self.agent_id, "module_id": self.module_id}
278 def json(self) -> str:
279 """Returns json serialization of the Source"""
280 return json.dumps(self.dict())
282 @classmethod
283 def create(cls, data: Union[dict, "Source", str]):
284 """Constructor for this class, used by pydantic."""
285 if isinstance(data, str):
286 return cls(agent_id=data)
287 if isinstance(data, Source):
288 return data
289 if isinstance(data, dict):
290 return cls(**data)
292 def matches(self, other) -> bool:
293 """
294 Define if the current source matches another source:
296 First, convert other object to a dict. The dict must contain
297 the variables agent_id and module_id. If one of the values is None,
298 it is excluded from the comparison.
299 Args:
300 other Union[Source, Dict]: Another source to compare
302 Returns:
303 Boolean if they match
304 """
305 if self.agent_id is None:
306 ag_match = True
307 else:
308 ag_match = self.agent_id == other.agent_id
309 if self.module_id is None:
310 mo_match = True
311 else:
312 mo_match = self.module_id == other.module_id
314 return mo_match and ag_match
317################################################################################
318# Variable Definitions
319################################################################################
320_TYPE_MAP: dict = {
321 "Real": float,
322 "Boolean": bool,
323 "Integer": int,
324 "Enumeration": int,
325 "float": float,
326 "int": int,
327 "str": str,
328 "bool": bool,
329 "pd.Series": pd.Series,
330 "list": list,
331}
334def none_to_inf(x):
335 """
336 Convert x to inf if it is None.
337 """
338 if x is None:
339 return math.inf
340 return x
343def none_to_minus_inf(x):
344 """
345 Convert x to -inf if it is None.
346 """
347 if x is None:
348 return -math.inf
349 return x
352@functools.lru_cache
353def slot_helper(cls: type) -> Set[str]:
354 """Return the set of all slots of a class and its parents.
356 This function is needed, as cls.__slots__ only returns the new slots that class
357 defined, but not the slots which it inherited. We have to manually traverse the
358 inheritance chain to get all slots.
359 Since this function is called whenever a variable has its dict called, we cache the
360 results for performance.
361 """
362 return set(chain.from_iterable(parent.__slots__ for parent in cls.mro()[:-1]))
365@define(slots=True, weakref_slot=False, kw_only=True)
366class BaseVariable(AttrsToPydanticAdaptor):
367 """
368 BaseVariable for all Variables inside the agentlib.
369 This includes Model Variables and AgentVariables.
370 A Variable can have an arbitrary value with several forms of validation for type,
371 boundaries and allowed values.
372 """
374 name: str = field(
375 metadata={"title": "Name", "description": "The name of the variable"}
376 )
377 type: Optional[str] = field(
378 default=None,
379 metadata={
380 "title": "Type",
381 "description": "Name the type of the variable using a string",
382 },
383 )
384 timestamp: Optional[float] = field(
385 default=None,
386 metadata={
387 "title": "Timestamp",
388 "description": "Timestamp of the current value",
389 },
390 )
391 unit: str = field(
392 default="Not defined",
393 metadata={"title": "Unit", "description": "Unit of the variable"},
394 )
395 description: str = field(
396 default="Not defined",
397 metadata={"title": "Description", "description": "Description of the variable"},
398 )
399 ub: Union[float, int] = field(
400 default=math.inf,
401 converter=none_to_inf,
402 metadata={
403 "title": "Upper boundary",
404 "description": "Upper bound of the variables value, "
405 "used only for numeric values.",
406 },
407 )
408 lb: Union[float, int] = field(
409 default=-math.inf,
410 converter=none_to_minus_inf,
411 metadata={
412 "title": "Lower boundary",
413 "description": "Lower bound of the variables value, "
414 "used only for numeric values.",
415 },
416 )
417 clip: bool = field(
418 default=False,
419 metadata={
420 "title": "Clip values to boundaries",
421 "description": "If set to true, values outside "
422 "of bounds will be clipped of",
423 },
424 )
425 # we use list here, because json deserializes to list by default. While membership
426 # check is slower with list, on average we think this is more performant, since
427 # the feature is not used very often
428 allowed_values: List[Any] = field(
429 default=[],
430 metadata={
431 "title": "Allowed values",
432 "description": "If provided, the value may only "
433 "be any value inside the given set "
434 "of allowed values. "
435 "Example would be to only allow only "
436 "the string options 'Create', 'Update' and "
437 "'Delete'. Then you should pass "
438 "allowed_values=['Create', 'Update', 'Delete']",
439 },
440 )
441 value: Any = field(
442 default=None,
443 metadata={"title": "Value", "description": "The value of the variable"},
444 )
446 @classmethod
447 def validate_data(cls, data: dict) -> "BaseVariable":
448 """Constructor that performs all validation."""
449 instance = cls(**data)
450 instance.run_validation()
451 return instance
453 @classmethod
454 def create(cls, data: Union[dict, "BaseVariable"]) -> "BaseVariable":
455 """Constructor for pydantic."""
456 if isinstance(data, cls):
457 return data
458 return cls.validate_data(data)
460 def run_validation(self):
461 """Performs all validations."""
462 self._check_bound()
463 self._check_value_type()
464 self._check_type_of_allowed_values()
465 self.set_value(self.value, validate=True)
467 def _check_bound(self):
468 """
469 First checks if the boundaries lb and ub are either numeric
470 or if they can be converted to a float. If they can, they
471 are converted.
472 Second, lower bound must be lower or equal than upper bound
473 """
474 for name, bound in {"lb": self.lb, "ub": self.ub}.items():
475 if isinstance(bound, numbers.Real):
476 continue
477 try:
478 self.__setattr__(name, float(bound))
479 except ValueError:
480 raise ValueError(f"Given bound {name} is not a valid number.")
481 if self.lb > self.ub:
482 raise ValueError("Given upper bound (ub) is lower than lower bound (lb)")
484 def _check_value_type(self):
485 """Validator for the type field. Makes sure the type is in the type map."""
486 if self.type is None:
487 return
488 if not isinstance(self.type, str):
489 raise TypeError(
490 f"Given types value is of type {type(self.type)} "
491 f"but should be a string."
492 )
493 if self.type not in _TYPE_MAP:
494 raise ValueError(
495 f"Given type '{self.type}' is not supported. "
496 f"Currently supported options are "
497 f"{', '.join(_TYPE_MAP.keys())}"
498 )
500 def _check_type_of_allowed_values(self):
501 """
502 Check if all given allowed values
503 are in line with the given type.
504 """
506 type_string = self.type
507 if type_string is None or not self.allowed_values:
508 return
509 if _TYPE_MAP[type_string] == pd.Series:
510 logger.error(
511 "The filed allowed_values is not "
512 "supported for pd.Series objects. "
513 "Equality is not proof-able in a clear way."
514 "Going to ignore the setting."
515 )
516 self.allowed_values = []
517 return
519 allowed_values_converted = []
520 for allowed_value in self.allowed_values:
521 value = _convert_value_to_type(value=allowed_value, type_string=type_string)
522 if _TYPE_MAP[type_string] in (float, int):
523 if (value < self.lb or value > self.ub) and self.clip:
524 raise ValueError(
525 f"Given allowed_value '{value}' is outside of given bounds. "
526 "Set of allowed values is hence infeasible."
527 )
528 allowed_values_converted.append(value)
529 self.allowed_values = allowed_values_converted
531 def set_value(self, value, validate: bool = False):
532 """
533 Sets the value of the variable. If validate is True (default False), also do
534 the following:
536 - Convert to the given type
537 - Check if inside the list of allowed_values
538 - If bounds can be applied, check if inside the given
539 bounds and maybe even clip accordingly
540 """
541 # Unpack given values and convert the value
542 if not validate:
543 self.value = value
544 return
546 name_string = self.name
547 value = _convert_value_to_type(value=value, type_string=self.type)
548 if isinstance(value, (float, int)):
549 # Else handle boundaries
550 if value > self.ub:
551 logger.error(
552 "Given value '%s' is higher than upper bound '%s' for '%s'",
553 value,
554 self.ub,
555 name_string,
556 )
557 if self.clip:
558 logger.error("Clipping value accordingly to '%s'", self.ub)
559 value = self.ub
560 if value < self.lb:
561 logger.error(
562 "Given value '%s' is lower than lower bound '%s' for '%s'",
563 value,
564 self.lb,
565 name_string,
566 )
567 if self.clip:
568 logger.error("Clipping value accordingly to '%s'", self.lb)
569 if isinstance(value, pd.Series):
570 value[value < self.lb] = self.lb
571 else:
572 value = self.lb
573 # Check if value is inside allowed values
574 if not self.allowed_values or isinstance(value, pd.Series):
575 self.value = value
576 return
578 if value not in self.allowed_values:
579 raise ValueError(
580 f"Given value for {name_string} is equal to {value} but has "
581 f"to be in the set of allowed values: "
582 f"{self.allowed_values}"
583 )
584 self.value = value
586 def dict(self, exclude: Container[str] = None) -> dict:
587 """Generates a dict from the Variable."""
588 slots = slot_helper(self.__class__)
589 if not exclude:
590 dump = {slot: self.__getattribute__(slot) for slot in slots}
591 else:
592 dump = {
593 slot: self.__getattribute__(slot)
594 for slot in slots
595 if slot not in exclude
596 }
597 if isinstance(self.value, pd.Series):
598 dump["value"] = self.value.to_dict()
599 return dump
601 def json(self) -> str:
602 """Serializes the Variable in json format and returns a string"""
603 dump = self.dict()
604 return json.dumps(dump, default=lambda o: o.dict())
607BaseVariableT = TypeVar("BaseVariableT", bound=BaseVariable)
610def _convert_value_to_type(value: Any, type_string: Optional[str]):
611 """Convert the given value to the type of the value"""
612 if type_string is None or value is None:
613 return value
615 type_of_value = _TYPE_MAP[type_string]
616 if isinstance(value, type_of_value):
617 return value # If already the type just return
618 try:
619 # Use the try block to pretty print any error occurring.
620 if type_of_value == pd.Series:
621 return convert_to_pd_series(value)
622 return type_of_value(value)
623 except Exception as err:
624 raise ValueError(
625 f"Given value '{value}' could not be converted "
626 f"to the specified type '{type_string}'. Error-message: "
627 f"\n{err}"
628 )
631def convert_to_pd_series(value):
632 if isinstance(value, str):
633 srs = pd.read_json(StringIO(value), typ="series")
634 elif isinstance(value, dict):
635 srs = pd.Series(value, dtype=np.float64)
636 else:
637 raise ValueError(
638 f"Specified a variable as a pd.Series, but the given value {value} "
639 f"could not be converted. Please pass a json string or a dict."
640 )
641 if isinstance(srs.index[0], str):
642 srs.index = srs.index.astype(float)
643 return srs
646@define(slots=True, weakref_slot=False, kw_only=True)
647class AgentVariable(BaseVariable):
648 """
649 The basic AgentVariable.
650 The AgentVariable is the central messaging piece in the AgentLib. It can hold
651 arbitrary (but json-serializable!) values as Agent States, Configuration objects or
652 messages.
654 In addition to fields defined in BaseVariable,
655 any AgentVariable holds the
656 - alias: The publicly known name of the variable
657 - source: Indicating which agent and or module the variable belong to
658 - shared: Whether the variable is going to be shared to other Agents
659 - rdf_class: Class in the resource description framework
661 Check the description of each field for further information.
662 """
664 alias: str = field(
665 default=None,
666 metadata={
667 "title": "Alias",
668 "description": "Alias, i.e. public name, of the AgentVariable",
669 },
670 )
671 source: Union[Source, str] = field(
672 default=Source(),
673 metadata={"title": "Place where the variable has been generated"},
674 converter=Source.create,
675 )
676 shared: Optional[bool] = field(
677 default=None,
678 metadata={
679 "title": "shared",
680 "description": "Indicates if the variable is going to be shared "
681 "with other agents. If False, no external "
682 "communication of this variable should take "
683 "place in any module.",
684 },
685 )
686 rdf_class: Optional[str] = field(
687 default=None,
688 metadata={
689 "title": "Class in the resource description framework (rdf). "
690 "Describes what of (real) object is represented by the"
691 "AgentVariable."
692 },
693 )
695 def __attrs_post_init__(self):
696 # when creating an AgentVariable not with cls.validate_data(), we still set
697 # the alias, but don't validate the value.
698 self._check_source()
699 self._check_alias()
701 def run_validation(self):
702 super().run_validation()
703 self._check_source()
704 self._check_alias()
706 def _check_alias(self):
707 """Sets the default value for the alias."""
708 if self.alias is None:
709 self.alias = self.name
711 @classmethod
712 def from_json(cls, s: Union[str, bytes], validate=False):
713 """Instantiates a new AgentVariable from json."""
714 data = json.loads(s)
715 data.setdefault("name", data["alias"])
716 if not validate:
717 variable = cls(**data)
718 # we do no validation, but we have to at least do type conversion
719 variable.value = _convert_value_to_type(variable.value, variable.type)
720 return variable
721 return cls.validate_data(data)
723 def _check_source(self):
724 """Convert possible str into source"""
725 if isinstance(self.source, str):
726 self.source = Source(agent_id=self.source)
728 def copy(self, update: Optional[dict] = None, deep: bool = False):
729 """Creates a copy of the Variable."""
730 _copy = copy(self)
731 if deep:
732 _copy.value = deepcopy(self.value)
733 if update:
734 for field, field_value in update.items():
735 _copy.__setattr__(field, field_value)
736 return _copy
738 def dict(self, exclude: Container[str] = None) -> dict:
739 result = super().dict(exclude)
740 if "source" in result:
741 # check needed in case source is in exclude
742 result["source"] = result["source"].dict()
743 return result
746@define(slots=True, weakref_slot=False, kw_only=True)
747class BaseModelVariable(BaseVariable):
748 """
749 Add the causalities used for model specific variables.
750 """
752 causality: Causality = field(
753 default=None,
754 metadata={"title": "causality", "description": "The causality of the variable"},
755 )
756 variability: Variability = field(
757 default=None,
758 metadata={
759 "title": "variability",
760 "description": "The variability of the variable",
761 },
762 )
763 type: Optional[str] = field(
764 default="float",
765 metadata={
766 "title": "Type",
767 "description": "Name the type of the variable using a string. For model "
768 "variables, this is float by default.",
769 },
770 )
772 def __attrs_post_init__(self):
773 # for model variables, we always want to validate them, since they are
774 # typically not created on the fly
775 self.run_validation()
777 def check_causality(self):
778 """Check if causality equals the default value.
779 Else, convert it to the default."""
780 default = attrs.fields(type(self)).causality.default
781 if default is not None:
782 if self.causality != default:
783 self.causality = default
785 def run_validation(self):
786 super().run_validation()
787 self.check_causality()
788 self.check_fmu_compliance()
790 def check_fmu_compliance(self):
791 """Check if combination of causality and variability
792 is supported according to fmu standard."""
794 if self.variability is None:
795 if self.causality in [Causality.parameter, Causality.calculatedParameter]:
796 self.variability = Variability.tunable
797 else:
798 self.variability = Variability.continuous
799 # Specify allowed combinations and reasons for them being not allowed:
800 # Source: FMU Standard
801 _reason_a = (
802 "The combinations “constant / parameter”, “constant / "
803 "calculatedParameter” and “constant / input” do not "
804 "make sense, since parameters and inputs "
805 "are set from the environment, whereas a constant "
806 "has always a value."
807 )
808 _reason_b = (
809 "The combinations “discrete / parameter”, “discrete / "
810 "calculatedParameter”, “continuous / parameter”and "
811 "continuous / calculatedParameter do not make sense, "
812 "since causality = “parameter” and “calculatedParameter” "
813 "define variables that do not depend on time, whereas “discrete” "
814 "and “continuous” define variables where the values can "
815 "change during simulation."
816 )
817 _reason_c = (
818 "For an “independent” variable only variability = “continuous” "
819 "makes sense."
820 )
821 _reason_d = (
822 "A fixed or tunable “input” has exactly the same properties "
823 "as a fixed or tunable parameter. For simplicity, only"
824 " fixed and tunable parameters shall be defined."
825 )
826 _reason_e = (
827 "A fixed or tunable “output” has exactly the same properties "
828 "as a fixed or tunable calculatedParameter. For simplicity, "
829 "only fixed and tunable calculatedParameters shall be defined"
830 )
831 _unsupported_combinations = {
832 (Causality.parameter, Variability.constant): _reason_a,
833 (Causality.parameter, Variability.discrete): _reason_b,
834 (Causality.parameter, Variability.continuous): _reason_b,
835 (Causality.calculatedParameter, Variability.constant): _reason_a,
836 (Causality.calculatedParameter, Variability.discrete): _reason_b,
837 (Causality.calculatedParameter, Variability.continuous): _reason_b,
838 (Causality.input, Variability.constant): _reason_a,
839 (Causality.input, Variability.fixed): _reason_d,
840 (Causality.input, Variability.tunable): _reason_d,
841 (Causality.output, Variability.fixed): _reason_e,
842 (Causality.output, Variability.tunable): _reason_e,
843 (Causality.independent, Variability.constant): _reason_c,
844 (Causality.independent, Variability.fixed): _reason_c,
845 (Causality.independent, Variability.tunable): _reason_c,
846 (Causality.independent, Variability.discrete): _reason_c,
847 }
849 _combination = (self.causality, self.variability)
850 # if combination is not supported, raise an error
851 assert _combination not in _unsupported_combinations, _unsupported_combinations[
852 _combination
853 ]
856@define(slots=True, weakref_slot=False, kw_only=True)
857class ModelVariable(BaseModelVariable):
858 """
859 The basic ModelVariable.
860 Aside from only allowing number for values,
861 this class enables calculation with the object itself.
862 """
864 sim_time: float = field(default=0.0, metadata={"title": "Current simulation time"})
866 def __add__(self, other):
867 return self.value + other
869 def __radd__(self, other):
870 return other + self.value
872 def __sub__(self, other):
873 return self.value - other
875 def __rsub__(self, other):
876 return other - self.value
878 def __mul__(self, other):
879 return self.value * other
881 def __rmul__(self, other):
882 return other * self.value
884 def __truediv__(self, other):
885 return self.value / other
887 def __rtruediv__(self, other):
888 return other / self.value
890 def __pow__(self, power, modulo=None):
891 return self.value**power
893 def __rpow__(self, other):
894 return other**self.value
897@define(slots=True, weakref_slot=False, kw_only=True)
898class ModelInput(ModelVariable):
899 """
900 The ModelInput variable.
901 Inherits
903 - BaseInput: The causality and variability associated with an input
904 - ModelVariable: The fields unique to a ModelVariable.
905 - BaseModelVariable: All fields associated with any model variable.
906 """
908 def __attrs_post_init__(self):
909 self.causality = Causality.input
910 self.variability = Variability.continuous
913@define(slots=True, weakref_slot=False, kw_only=True)
914class ModelOutput(ModelVariable):
915 """
916 The ModelOutput variable.
917 Inherits
919 - BaseOutput: The causality and variability associated with an output
920 - ModelVariable: The fields unique to a ModelVariable.
921 - BaseModelVariable: All fields associated with any model variable.
922 """
924 def __attrs_post_init__(self):
925 self.causality: Causality = Causality.output
926 self.variability: Variability = Variability.continuous
929@define(slots=True, weakref_slot=False, kw_only=True)
930class ModelState(ModelVariable):
931 """
932 The ModelState variable.
933 Inherits
935 - BaseLocal: The causality and variability associated with a local
936 - ModelVariable: The fields unique to a ModelVariable.
937 - BaseModelVariable: All fields associated with any model variable.
938 """
940 def __attrs_post_init__(self):
941 self.causality: Causality = Causality.local
942 self.variability: Variability = Variability.continuous
945@define(slots=True, weakref_slot=False, kw_only=True)
946class ModelParameter(ModelVariable):
947 """
948 The ModelParameter variable.
949 Inherits
951 - BaseParameter: The causality and variability associated with a parameter
952 - ModelVariable: The fields unique to a ModelVariable.
953 """
955 def __attrs_post_init__(self):
956 self.causality: Causality = Causality.parameter
957 self.variability: Variability = Variability.tunable
960# Types section
961# Agents
962AgentVariables = List[AgentVariable]
963# Models
964ModelInputs = List[ModelInput]
965ModelStates = List[ModelState]
966ModelOutputs = List[ModelOutput]
967ModelParameters = List[ModelParameter]