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

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 

16from typing import Union, Any, List, Optional, TypeVar, Set, Container, get_args 

17 

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 

25 

26logger = logging.getLogger(__name__) 

27 

28 

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] 

44 

45 

46__all__ = ATTRS_MODELS + [ 

47 "Causality", 

48 "Variability", 

49] 

50 

51 

52class Causality(str, Enum): 

53 """ 

54 Enumeration that defines the causality of a variable. 

55 

56 The default causality is “local”. 

57 

58 Allowed params of his enumeration: 

59 """ 

60 

61 # _init_ = "value __doc__" 

62 parameter = "parameter" 

63 calculatedParameter = "calculatedParameter" 

64 

65 input = "input" 

66 output = "output" 

67 local = "local" 

68 independent = "independent" 

69 

70 

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

80 

81 The default is “continuous” 

82 

83 Allowed params of this enumeration: 

84 """ 

85 

86 constant = "constant" 

87 fixed = "fixed" 

88 tunable = "tunable" 

89 discrete = "discrete" 

90 continuous = "continuous" 

91 

92 

93############################################################################### 

94# Custom Field types 

95############################################################################### 

96 

97 

98class AttrsToPydanticAdaptor(abc.ABC): 

99 """ 

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

101 """ 

102 

103 @abc.abstractmethod 

104 def dict(self): 

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

106 raise NotImplementedError 

107 

108 @abc.abstractmethod 

109 def json(self) -> str: 

110 """Returns json serialization of the class""" 

111 raise NotImplementedError 

112 

113 @classmethod 

114 @abc.abstractmethod 

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

116 raise NotImplementedError 

117 

118 def _serialize(self, _info: SerializationInfo): 

119 """Function required for pydantic""" 

120 if _info.mode == "python": 

121 return self.dict() 

122 return self.json() 

123 

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 ) 

138 

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

149 

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. 

157 

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

174 

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

180 

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) 

193 

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 ) 

213 

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) 

227 

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 } 

239 

240 return schema 

241 

242 

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. 

250 

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

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

253 """ 

254 

255 agent_id: Optional[str] = None 

256 module_id: Optional[str] = None 

257 

258 def __str__(self): 

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

260 

261 def __hash__(self): 

262 return hash(str(self)) 

263 

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 

272 

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} 

277 

278 def json(self) -> str: 

279 """Returns json serialization of the Source""" 

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

281 

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) 

291 

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

293 """ 

294 Define if the current source matches another source: 

295 

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 

301 

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 

313 

314 return mo_match and ag_match 

315 

316 

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} 

332 

333 

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 

341 

342 

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 

350 

351 

352@functools.lru_cache 

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

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

355 

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

363 

364 

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

373 

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 ) 

445 

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 

452 

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) 

459 

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) 

466 

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

483 

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 ) 

499 

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

505 

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 

518 

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 

530 

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: 

535 

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 

545 

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 

577 

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 

585 

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 

600 

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

605 

606 

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

608 

609 

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 

614 

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 ) 

629 

630 

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 

646 

647 

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. 

655 

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 

662 

663 Check the description of each field for further information. 

664 """ 

665 

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 ) 

696 

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

702 

703 def run_validation(self): 

704 super().run_validation() 

705 self._check_source() 

706 self._check_alias() 

707 

708 def _check_alias(self): 

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

710 if self.alias is None: 

711 self.alias = self.name 

712 

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) 

724 

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) 

729 

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 

739 

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 

746 

747 

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

749class BaseModelVariable(BaseVariable): 

750 """ 

751 Add the causalities used for model specific variables. 

752 """ 

753 

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 ) 

773 

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

778 

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 

786 

787 def run_validation(self): 

788 super().run_validation() 

789 self.check_causality() 

790 self.check_fmu_compliance() 

791 

792 def check_fmu_compliance(self): 

793 """Check if combination of causality and variability 

794 is supported according to fmu standard.""" 

795 

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 } 

850 

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 ] 

856 

857 

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

865 

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

867 

868 def __add__(self, other): 

869 return self.value + other 

870 

871 def __radd__(self, other): 

872 return other + self.value 

873 

874 def __sub__(self, other): 

875 return self.value - other 

876 

877 def __rsub__(self, other): 

878 return other - self.value 

879 

880 def __mul__(self, other): 

881 return self.value * other 

882 

883 def __rmul__(self, other): 

884 return other * self.value 

885 

886 def __truediv__(self, other): 

887 return self.value / other 

888 

889 def __rtruediv__(self, other): 

890 return other / self.value 

891 

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

893 return self.value**power 

894 

895 def __rpow__(self, other): 

896 return other**self.value 

897 

898 

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

900class ModelInput(ModelVariable): 

901 """ 

902 The ModelInput variable. 

903 Inherits 

904 

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

909 

910 def __attrs_post_init__(self): 

911 self.causality = Causality.input 

912 self.variability = Variability.continuous 

913 

914 

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

916class ModelOutput(ModelVariable): 

917 """ 

918 The ModelOutput variable. 

919 Inherits 

920 

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

925 

926 def __attrs_post_init__(self): 

927 self.causality: Causality = Causality.output 

928 self.variability: Variability = Variability.continuous 

929 

930 

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

932class ModelState(ModelVariable): 

933 """ 

934 The ModelState variable. 

935 Inherits 

936 

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

941 

942 def __attrs_post_init__(self): 

943 self.causality: Causality = Causality.local 

944 self.variability: Variability = Variability.continuous 

945 

946 

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

948class ModelParameter(ModelVariable): 

949 """ 

950 The ModelParameter variable. 

951 Inherits 

952 

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

954 - ModelVariable: The fields unique to a ModelVariable. 

955 """ 

956 

957 def __attrs_post_init__(self): 

958 self.causality: Causality = Causality.parameter 

959 self.variability: Variability = Variability.tunable 

960 

961 

962# Types section 

963# Agents 

964AgentVariables = List[AgentVariable] 

965# Models 

966ModelInputs = List[ModelInput] 

967ModelStates = List[ModelState] 

968ModelOutputs = List[ModelOutput] 

969ModelParameters = List[ModelParameter]