Coverage for agentlib/core/datamodels.py: 96%

366 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2025-04-07 16:27 +0000

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 return srs 

644 

645 

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

647class AgentVariable(BaseVariable): 

648 """ 

649 The basic AgentVariable. 

650 The AgentVariable is the central messaging piece in the AgentLib. It can hold 

651 arbitrary (but json-serializable!) values as Agent States, Configuration objects or 

652 messages. 

653 

654 In addition to fields defined in BaseVariable, 

655 any AgentVariable holds the 

656 - alias: The publicly known name of the variable 

657 - source: Indicating which agent and or module the variable belong to 

658 - shared: Whether the variable is going to be shared to other Agents 

659 - rdf_class: Class in the resource description framework 

660 

661 Check the description of each field for further information. 

662 """ 

663 

664 alias: str = field( 

665 default=None, 

666 metadata={ 

667 "title": "Alias", 

668 "description": "Alias, i.e. public name, of the AgentVariable", 

669 }, 

670 ) 

671 source: Union[Source, str] = field( 

672 default=Source(), 

673 metadata={"title": "Place where the variable has been generated"}, 

674 converter=Source.create, 

675 ) 

676 shared: Optional[bool] = field( 

677 default=None, 

678 metadata={ 

679 "title": "shared", 

680 "description": "Indicates if the variable is going to be shared " 

681 "with other agents. If False, no external " 

682 "communication of this variable should take " 

683 "place in any module.", 

684 }, 

685 ) 

686 rdf_class: Optional[str] = field( 

687 default=None, 

688 metadata={ 

689 "title": "Class in the resource description framework (rdf). " 

690 "Describes what of (real) object is represented by the" 

691 "AgentVariable." 

692 }, 

693 ) 

694 

695 def __attrs_post_init__(self): 

696 # when creating an AgentVariable not with cls.validate_data(), we still set 

697 # the alias, but don't validate the value. 

698 self._check_source() 

699 self._check_alias() 

700 

701 def run_validation(self): 

702 super().run_validation() 

703 self._check_source() 

704 self._check_alias() 

705 

706 def _check_alias(self): 

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

708 if self.alias is None: 

709 self.alias = self.name 

710 

711 @classmethod 

712 def from_json(cls, s: Union[str, bytes], validate=False): 

713 """Instantiates a new AgentVariable from json.""" 

714 data = json.loads(s) 

715 data.setdefault("name", data["alias"]) 

716 if not validate: 

717 variable = cls(**data) 

718 # we do no validation, but we have to at least do type conversion 

719 variable.value = _convert_value_to_type(variable.value, variable.type) 

720 return variable 

721 return cls.validate_data(data) 

722 

723 def _check_source(self): 

724 """Convert possible str into source""" 

725 if isinstance(self.source, str): 

726 self.source = Source(agent_id=self.source) 

727 

728 def copy(self, update: Optional[dict] = None, deep: bool = False): 

729 """Creates a copy of the Variable.""" 

730 _copy = copy(self) 

731 if deep: 

732 _copy.value = deepcopy(self.value) 

733 if update: 

734 for field, field_value in update.items(): 

735 _copy.__setattr__(field, field_value) 

736 return _copy 

737 

738 def dict(self, exclude: Container[str] = None) -> dict: 

739 result = super().dict(exclude) 

740 if "source" in result: 

741 # check needed in case source is in exclude 

742 result["source"] = result["source"].dict() 

743 return result 

744 

745 

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

747class BaseModelVariable(BaseVariable): 

748 """ 

749 Add the causalities used for model specific variables. 

750 """ 

751 

752 causality: Causality = field( 

753 default=None, 

754 metadata={"title": "causality", "description": "The causality of the variable"}, 

755 ) 

756 variability: Variability = field( 

757 default=None, 

758 metadata={ 

759 "title": "variability", 

760 "description": "The variability of the variable", 

761 }, 

762 ) 

763 type: Optional[str] = field( 

764 default="float", 

765 metadata={ 

766 "title": "Type", 

767 "description": "Name the type of the variable using a string. For model " 

768 "variables, this is float by default.", 

769 }, 

770 ) 

771 

772 def __attrs_post_init__(self): 

773 # for model variables, we always want to validate them, since they are 

774 # typically not created on the fly 

775 self.run_validation() 

776 

777 def check_causality(self): 

778 """Check if causality equals the default value. 

779 Else, convert it to the default.""" 

780 default = attrs.fields(type(self)).causality.default 

781 if default is not None: 

782 if self.causality != default: 

783 self.causality = default 

784 

785 def run_validation(self): 

786 super().run_validation() 

787 self.check_causality() 

788 self.check_fmu_compliance() 

789 

790 def check_fmu_compliance(self): 

791 """Check if combination of causality and variability 

792 is supported according to fmu standard.""" 

793 

794 if self.variability is None: 

795 if self.causality in [Causality.parameter, Causality.calculatedParameter]: 

796 self.variability = Variability.tunable 

797 else: 

798 self.variability = Variability.continuous 

799 # Specify allowed combinations and reasons for them being not allowed: 

800 # Source: FMU Standard 

801 _reason_a = ( 

802 "The combinations “constant / parameter”, “constant / " 

803 "calculatedParameter” and “constant / input” do not " 

804 "make sense, since parameters and inputs " 

805 "are set from the environment, whereas a constant " 

806 "has always a value." 

807 ) 

808 _reason_b = ( 

809 "The combinations “discrete / parameter”, “discrete / " 

810 "calculatedParameter”, “continuous / parameter”and " 

811 "continuous / calculatedParameter do not make sense, " 

812 "since causality = “parameter” and “calculatedParameter” " 

813 "define variables that do not depend on time, whereas “discrete” " 

814 "and “continuous” define variables where the values can " 

815 "change during simulation." 

816 ) 

817 _reason_c = ( 

818 "For an “independent” variable only variability = “continuous” " 

819 "makes sense." 

820 ) 

821 _reason_d = ( 

822 "A fixed or tunable “input” has exactly the same properties " 

823 "as a fixed or tunable parameter. For simplicity, only" 

824 " fixed and tunable parameters shall be defined." 

825 ) 

826 _reason_e = ( 

827 "A fixed or tunable “output” has exactly the same properties " 

828 "as a fixed or tunable calculatedParameter. For simplicity, " 

829 "only fixed and tunable calculatedParameters shall be defined" 

830 ) 

831 _unsupported_combinations = { 

832 (Causality.parameter, Variability.constant): _reason_a, 

833 (Causality.parameter, Variability.discrete): _reason_b, 

834 (Causality.parameter, Variability.continuous): _reason_b, 

835 (Causality.calculatedParameter, Variability.constant): _reason_a, 

836 (Causality.calculatedParameter, Variability.discrete): _reason_b, 

837 (Causality.calculatedParameter, Variability.continuous): _reason_b, 

838 (Causality.input, Variability.constant): _reason_a, 

839 (Causality.input, Variability.fixed): _reason_d, 

840 (Causality.input, Variability.tunable): _reason_d, 

841 (Causality.output, Variability.fixed): _reason_e, 

842 (Causality.output, Variability.tunable): _reason_e, 

843 (Causality.independent, Variability.constant): _reason_c, 

844 (Causality.independent, Variability.fixed): _reason_c, 

845 (Causality.independent, Variability.tunable): _reason_c, 

846 (Causality.independent, Variability.discrete): _reason_c, 

847 } 

848 

849 _combination = (self.causality, self.variability) 

850 # if combination is not supported, raise an error 

851 assert _combination not in _unsupported_combinations, _unsupported_combinations[ 

852 _combination 

853 ] 

854 

855 

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

857class ModelVariable(BaseModelVariable): 

858 """ 

859 The basic ModelVariable. 

860 Aside from only allowing number for values, 

861 this class enables calculation with the object itself. 

862 """ 

863 

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

865 

866 def __add__(self, other): 

867 return self.value + other 

868 

869 def __radd__(self, other): 

870 return other + self.value 

871 

872 def __sub__(self, other): 

873 return self.value - other 

874 

875 def __rsub__(self, other): 

876 return other - self.value 

877 

878 def __mul__(self, other): 

879 return self.value * other 

880 

881 def __rmul__(self, other): 

882 return other * self.value 

883 

884 def __truediv__(self, other): 

885 return self.value / other 

886 

887 def __rtruediv__(self, other): 

888 return other / self.value 

889 

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

891 return self.value**power 

892 

893 def __rpow__(self, other): 

894 return other**self.value 

895 

896 

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

898class ModelInput(ModelVariable): 

899 """ 

900 The ModelInput variable. 

901 Inherits 

902 

903 - BaseInput: The causality and variability associated with an input 

904 - ModelVariable: The fields unique to a ModelVariable. 

905 - BaseModelVariable: All fields associated with any model variable. 

906 """ 

907 

908 def __attrs_post_init__(self): 

909 self.causality = Causality.input 

910 self.variability = Variability.continuous 

911 

912 

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

914class ModelOutput(ModelVariable): 

915 """ 

916 The ModelOutput variable. 

917 Inherits 

918 

919 - BaseOutput: The causality and variability associated with an output 

920 - ModelVariable: The fields unique to a ModelVariable. 

921 - BaseModelVariable: All fields associated with any model variable. 

922 """ 

923 

924 def __attrs_post_init__(self): 

925 self.causality: Causality = Causality.output 

926 self.variability: Variability = Variability.continuous 

927 

928 

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

930class ModelState(ModelVariable): 

931 """ 

932 The ModelState variable. 

933 Inherits 

934 

935 - BaseLocal: The causality and variability associated with a local 

936 - ModelVariable: The fields unique to a ModelVariable. 

937 - BaseModelVariable: All fields associated with any model variable. 

938 """ 

939 

940 def __attrs_post_init__(self): 

941 self.causality: Causality = Causality.local 

942 self.variability: Variability = Variability.continuous 

943 

944 

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

946class ModelParameter(ModelVariable): 

947 """ 

948 The ModelParameter variable. 

949 Inherits 

950 

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

952 - ModelVariable: The fields unique to a ModelVariable. 

953 """ 

954 

955 def __attrs_post_init__(self): 

956 self.causality: Causality = Causality.parameter 

957 self.variability: Variability = Variability.tunable 

958 

959 

960# Types section 

961# Agents 

962AgentVariables = List[AgentVariable] 

963# Models 

964ModelInputs = List[ModelInput] 

965ModelStates = List[ModelState] 

966ModelOutputs = List[ModelOutput] 

967ModelParameters = List[ModelParameter]