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

1""" 

2The datamodels module contains all classes 

3defining basic models to handle data. 

4""" 

5 

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 

18 

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 

26 

27logger = logging.getLogger(__name__) 

28 

29 

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] 

45 

46 

47__all__ = ATTRS_MODELS + [ 

48 "Causality", 

49 "Variability", 

50] 

51 

52 

53class Causality(str, Enum): 

54 """ 

55 Enumeration that defines the causality of a variable. 

56 

57 The default causality is “local”. 

58 

59 Allowed params of his enumeration: 

60 """ 

61 

62 # _init_ = "value __doc__" 

63 parameter = "parameter" 

64 calculatedParameter = "calculatedParameter" 

65 

66 input = "input" 

67 output = "output" 

68 local = "local" 

69 independent = "independent" 

70 

71 

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]. 

81 

82 The default is “continuous” 

83 

84 Allowed params of this enumeration: 

85 """ 

86 

87 constant = "constant" 

88 fixed = "fixed" 

89 tunable = "tunable" 

90 discrete = "discrete" 

91 continuous = "continuous" 

92 

93 

94############################################################################### 

95# Custom Field types 

96############################################################################### 

97 

98 

99class AttrsToPydanticAdaptor(abc.ABC): 

100 """ 

101 Class to use the attrs-based class in pydantic models. 

102 """ 

103 

104 @abc.abstractmethod 

105 def dict(self): 

106 """Returns the dict object of the class.""" 

107 raise NotImplementedError 

108 

109 @abc.abstractmethod 

110 def json(self) -> str: 

111 """Returns json serialization of the class""" 

112 raise NotImplementedError 

113 

114 @classmethod 

115 @abc.abstractmethod 

116 def create(cls, data: Union[dict, "AttrsToPydanticAdaptor"]): 

117 raise NotImplementedError 

118 

119 def _serialize(self, _info: SerializationInfo): 

120 """Function required for pydantic""" 

121 if _info.mode == "python": 

122 return self.dict() 

123 return self.json() 

124 

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 ) 

139 

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 """ 

150 

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. 

158 

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]}, [] 

175 

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]. 

181 

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) 

194 

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 ) 

216 

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) 

230 

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 } 

242 

243 return schema 

244 

245 

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. 

253 

254 However, with methods like 'matches', one can indicate 

255 setting any id to None that the id is irrelevant. 

256 """ 

257 

258 agent_id: Optional[str] = None 

259 module_id: Optional[str] = None 

260 

261 def __str__(self): 

262 return f"{self.agent_id}_{self.module_id}" 

263 

264 def __hash__(self): 

265 return hash(str(self)) 

266 

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 

275 

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} 

280 

281 def json(self) -> str: 

282 """Returns json serialization of the Source""" 

283 return json.dumps(self.dict()) 

284 

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) 

294 

295 def matches(self, other) -> bool: 

296 """ 

297 Define if the current source matches another source: 

298 

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 

304 

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 

316 

317 return mo_match and ag_match 

318 

319 

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} 

335 

336 

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 

344 

345 

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 

353 

354 

355@functools.lru_cache 

356def slot_helper(cls: type) -> Set[str]: 

357 """Return the set of all slots of a class and its parents. 

358 

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])) 

366 

367 

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 """ 

376 

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 ) 

448 

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 

455 

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) 

462 

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) 

469 

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)") 

486 

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 ) 

502 

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 """ 

508 

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 

521 

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 

533 

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: 

538 

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 

548 

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 

580 

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 

588 

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 

603 

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()) 

608 

609 

610BaseVariableT = TypeVar("BaseVariableT", bound=BaseVariable) 

611 

612 

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 

617 

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 ) 

632 

633 

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 

649 

650 

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. 

658 

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 

665 

666 Check the description of each field for further information. 

667 """ 

668 

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 ) 

699 

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() 

705 

706 def run_validation(self): 

707 super().run_validation() 

708 self._check_source() 

709 self._check_alias() 

710 

711 def _check_alias(self): 

712 """Sets the default value for the alias.""" 

713 if self.alias is None: 

714 self.alias = self.name 

715 

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) 

727 

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) 

732 

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 

742 

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 

749 

750 

751@define(slots=True, weakref_slot=False, kw_only=True) 

752class BaseModelVariable(BaseVariable): 

753 """ 

754 Add the causalities used for model specific variables. 

755 """ 

756 

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 ) 

776 

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() 

781 

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 

789 

790 def run_validation(self): 

791 super().run_validation() 

792 self.check_causality() 

793 self.check_fmu_compliance() 

794 

795 def check_fmu_compliance(self): 

796 """Check if combination of causality and variability 

797 is supported according to fmu standard.""" 

798 

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 } 

853 

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 ] 

859 

860 

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 """ 

868 

869 sim_time: float = field(default=0.0, metadata={"title": "Current simulation time"}) 

870 

871 def __add__(self, other): 

872 return self.value + other 

873 

874 def __radd__(self, other): 

875 return other + self.value 

876 

877 def __sub__(self, other): 

878 return self.value - other 

879 

880 def __rsub__(self, other): 

881 return other - self.value 

882 

883 def __mul__(self, other): 

884 return self.value * other 

885 

886 def __rmul__(self, other): 

887 return other * self.value 

888 

889 def __truediv__(self, other): 

890 return self.value / other 

891 

892 def __rtruediv__(self, other): 

893 return other / self.value 

894 

895 def __pow__(self, power, modulo=None): 

896 return self.value**power 

897 

898 def __rpow__(self, other): 

899 return other**self.value 

900 

901 

902@define(slots=True, weakref_slot=False, kw_only=True) 

903class ModelInput(ModelVariable): 

904 """ 

905 The ModelInput variable. 

906 Inherits 

907 

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 """ 

912 

913 def __attrs_post_init__(self): 

914 self.causality = Causality.input 

915 self.variability = Variability.continuous 

916 

917 

918@define(slots=True, weakref_slot=False, kw_only=True) 

919class ModelOutput(ModelVariable): 

920 """ 

921 The ModelOutput variable. 

922 Inherits 

923 

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 """ 

928 

929 def __attrs_post_init__(self): 

930 self.causality: Causality = Causality.output 

931 self.variability: Variability = Variability.continuous 

932 

933 

934@define(slots=True, weakref_slot=False, kw_only=True) 

935class ModelState(ModelVariable): 

936 """ 

937 The ModelState variable. 

938 Inherits 

939 

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 """ 

944 

945 def __attrs_post_init__(self): 

946 self.causality: Causality = Causality.local 

947 self.variability: Variability = Variability.continuous 

948 

949 

950@define(slots=True, weakref_slot=False, kw_only=True) 

951class ModelParameter(ModelVariable): 

952 """ 

953 The ModelParameter variable. 

954 Inherits 

955 

956 - BaseParameter: The causality and variability associated with a parameter 

957 - ModelVariable: The fields unique to a ModelVariable. 

958 """ 

959 

960 def __attrs_post_init__(self): 

961 self.causality: Causality = Causality.parameter 

962 self.variability: Variability = Variability.tunable 

963 

964 

965# Types section 

966# Agents 

967AgentVariables = List[AgentVariable] 

968# Models 

969ModelInputs = List[ModelInput] 

970ModelStates = List[ModelState] 

971ModelOutputs = List[ModelOutput] 

972ModelParameters = List[ModelParameter]