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