Coverage for agentlib/core/datamodels.py: 96%
368 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-04-30 13:00 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2025-04-30 13:00 +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 if isinstance(srs.index, pd.DatetimeIndex):
644 srs.index = pd.to_numeric(srs.index) / 10**9
645 return srs
648@define(slots=True, weakref_slot=False, kw_only=True)
649class AgentVariable(BaseVariable):
650 """
651 The basic AgentVariable.
652 The AgentVariable is the central messaging piece in the AgentLib. It can hold
653 arbitrary (but json-serializable!) values as Agent States, Configuration objects or
654 messages.
656 In addition to fields defined in BaseVariable,
657 any AgentVariable holds the
658 - alias: The publicly known name of the variable
659 - source: Indicating which agent and or module the variable belong to
660 - shared: Whether the variable is going to be shared to other Agents
661 - rdf_class: Class in the resource description framework
663 Check the description of each field for further information.
664 """
666 alias: str = field(
667 default=None,
668 metadata={
669 "title": "Alias",
670 "description": "Alias, i.e. public name, of the AgentVariable",
671 },
672 )
673 source: Union[Source, str] = field(
674 default=Source(),
675 metadata={"title": "Place where the variable has been generated"},
676 converter=Source.create,
677 )
678 shared: Optional[bool] = field(
679 default=None,
680 metadata={
681 "title": "shared",
682 "description": "Indicates if the variable is going to be shared "
683 "with other agents. If False, no external "
684 "communication of this variable should take "
685 "place in any module.",
686 },
687 )
688 rdf_class: Optional[str] = field(
689 default=None,
690 metadata={
691 "title": "Class in the resource description framework (rdf). "
692 "Describes what of (real) object is represented by the"
693 "AgentVariable."
694 },
695 )
697 def __attrs_post_init__(self):
698 # when creating an AgentVariable not with cls.validate_data(), we still set
699 # the alias, but don't validate the value.
700 self._check_source()
701 self._check_alias()
703 def run_validation(self):
704 super().run_validation()
705 self._check_source()
706 self._check_alias()
708 def _check_alias(self):
709 """Sets the default value for the alias."""
710 if self.alias is None:
711 self.alias = self.name
713 @classmethod
714 def from_json(cls, s: Union[str, bytes], validate=False):
715 """Instantiates a new AgentVariable from json."""
716 data = json.loads(s)
717 data.setdefault("name", data["alias"])
718 if not validate:
719 variable = cls(**data)
720 # we do no validation, but we have to at least do type conversion
721 variable.value = _convert_value_to_type(variable.value, variable.type)
722 return variable
723 return cls.validate_data(data)
725 def _check_source(self):
726 """Convert possible str into source"""
727 if isinstance(self.source, str):
728 self.source = Source(agent_id=self.source)
730 def copy(self, update: Optional[dict] = None, deep: bool = False):
731 """Creates a copy of the Variable."""
732 _copy = copy(self)
733 if deep:
734 _copy.value = deepcopy(self.value)
735 if update:
736 for field, field_value in update.items():
737 _copy.__setattr__(field, field_value)
738 return _copy
740 def dict(self, exclude: Container[str] = None) -> dict:
741 result = super().dict(exclude)
742 if "source" in result:
743 # check needed in case source is in exclude
744 result["source"] = result["source"].dict()
745 return result
748@define(slots=True, weakref_slot=False, kw_only=True)
749class BaseModelVariable(BaseVariable):
750 """
751 Add the causalities used for model specific variables.
752 """
754 causality: Causality = field(
755 default=None,
756 metadata={"title": "causality", "description": "The causality of the variable"},
757 )
758 variability: Variability = field(
759 default=None,
760 metadata={
761 "title": "variability",
762 "description": "The variability of the variable",
763 },
764 )
765 type: Optional[str] = field(
766 default="float",
767 metadata={
768 "title": "Type",
769 "description": "Name the type of the variable using a string. For model "
770 "variables, this is float by default.",
771 },
772 )
774 def __attrs_post_init__(self):
775 # for model variables, we always want to validate them, since they are
776 # typically not created on the fly
777 self.run_validation()
779 def check_causality(self):
780 """Check if causality equals the default value.
781 Else, convert it to the default."""
782 default = attrs.fields(type(self)).causality.default
783 if default is not None:
784 if self.causality != default:
785 self.causality = default
787 def run_validation(self):
788 super().run_validation()
789 self.check_causality()
790 self.check_fmu_compliance()
792 def check_fmu_compliance(self):
793 """Check if combination of causality and variability
794 is supported according to fmu standard."""
796 if self.variability is None:
797 if self.causality in [Causality.parameter, Causality.calculatedParameter]:
798 self.variability = Variability.tunable
799 else:
800 self.variability = Variability.continuous
801 # Specify allowed combinations and reasons for them being not allowed:
802 # Source: FMU Standard
803 _reason_a = (
804 "The combinations “constant / parameter”, “constant / "
805 "calculatedParameter” and “constant / input” do not "
806 "make sense, since parameters and inputs "
807 "are set from the environment, whereas a constant "
808 "has always a value."
809 )
810 _reason_b = (
811 "The combinations “discrete / parameter”, “discrete / "
812 "calculatedParameter”, “continuous / parameter”and "
813 "continuous / calculatedParameter do not make sense, "
814 "since causality = “parameter” and “calculatedParameter” "
815 "define variables that do not depend on time, whereas “discrete” "
816 "and “continuous” define variables where the values can "
817 "change during simulation."
818 )
819 _reason_c = (
820 "For an “independent” variable only variability = “continuous” "
821 "makes sense."
822 )
823 _reason_d = (
824 "A fixed or tunable “input” has exactly the same properties "
825 "as a fixed or tunable parameter. For simplicity, only"
826 " fixed and tunable parameters shall be defined."
827 )
828 _reason_e = (
829 "A fixed or tunable “output” has exactly the same properties "
830 "as a fixed or tunable calculatedParameter. For simplicity, "
831 "only fixed and tunable calculatedParameters shall be defined"
832 )
833 _unsupported_combinations = {
834 (Causality.parameter, Variability.constant): _reason_a,
835 (Causality.parameter, Variability.discrete): _reason_b,
836 (Causality.parameter, Variability.continuous): _reason_b,
837 (Causality.calculatedParameter, Variability.constant): _reason_a,
838 (Causality.calculatedParameter, Variability.discrete): _reason_b,
839 (Causality.calculatedParameter, Variability.continuous): _reason_b,
840 (Causality.input, Variability.constant): _reason_a,
841 (Causality.input, Variability.fixed): _reason_d,
842 (Causality.input, Variability.tunable): _reason_d,
843 (Causality.output, Variability.fixed): _reason_e,
844 (Causality.output, Variability.tunable): _reason_e,
845 (Causality.independent, Variability.constant): _reason_c,
846 (Causality.independent, Variability.fixed): _reason_c,
847 (Causality.independent, Variability.tunable): _reason_c,
848 (Causality.independent, Variability.discrete): _reason_c,
849 }
851 _combination = (self.causality, self.variability)
852 # if combination is not supported, raise an error
853 assert _combination not in _unsupported_combinations, _unsupported_combinations[
854 _combination
855 ]
858@define(slots=True, weakref_slot=False, kw_only=True)
859class ModelVariable(BaseModelVariable):
860 """
861 The basic ModelVariable.
862 Aside from only allowing number for values,
863 this class enables calculation with the object itself.
864 """
866 sim_time: float = field(default=0.0, metadata={"title": "Current simulation time"})
868 def __add__(self, other):
869 return self.value + other
871 def __radd__(self, other):
872 return other + self.value
874 def __sub__(self, other):
875 return self.value - other
877 def __rsub__(self, other):
878 return other - self.value
880 def __mul__(self, other):
881 return self.value * other
883 def __rmul__(self, other):
884 return other * self.value
886 def __truediv__(self, other):
887 return self.value / other
889 def __rtruediv__(self, other):
890 return other / self.value
892 def __pow__(self, power, modulo=None):
893 return self.value**power
895 def __rpow__(self, other):
896 return other**self.value
899@define(slots=True, weakref_slot=False, kw_only=True)
900class ModelInput(ModelVariable):
901 """
902 The ModelInput variable.
903 Inherits
905 - BaseInput: The causality and variability associated with an input
906 - ModelVariable: The fields unique to a ModelVariable.
907 - BaseModelVariable: All fields associated with any model variable.
908 """
910 def __attrs_post_init__(self):
911 self.causality = Causality.input
912 self.variability = Variability.continuous
915@define(slots=True, weakref_slot=False, kw_only=True)
916class ModelOutput(ModelVariable):
917 """
918 The ModelOutput variable.
919 Inherits
921 - BaseOutput: The causality and variability associated with an output
922 - ModelVariable: The fields unique to a ModelVariable.
923 - BaseModelVariable: All fields associated with any model variable.
924 """
926 def __attrs_post_init__(self):
927 self.causality: Causality = Causality.output
928 self.variability: Variability = Variability.continuous
931@define(slots=True, weakref_slot=False, kw_only=True)
932class ModelState(ModelVariable):
933 """
934 The ModelState variable.
935 Inherits
937 - BaseLocal: The causality and variability associated with a local
938 - ModelVariable: The fields unique to a ModelVariable.
939 - BaseModelVariable: All fields associated with any model variable.
940 """
942 def __attrs_post_init__(self):
943 self.causality: Causality = Causality.local
944 self.variability: Variability = Variability.continuous
947@define(slots=True, weakref_slot=False, kw_only=True)
948class ModelParameter(ModelVariable):
949 """
950 The ModelParameter variable.
951 Inherits
953 - BaseParameter: The causality and variability associated with a parameter
954 - ModelVariable: The fields unique to a ModelVariable.
955 """
957 def __attrs_post_init__(self):
958 self.causality: Causality = Causality.parameter
959 self.variability: Variability = Variability.tunable
962# Types section
963# Agents
964AgentVariables = List[AgentVariable]
965# Models
966ModelInputs = List[ModelInput]
967ModelStates = List[ModelState]
968ModelOutputs = List[ModelOutput]
969ModelParameters = List[ModelParameter]