Coverage for bim2sim/export/modelica/__init__.py: 80%

299 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 17:09 +0000

1"""Package for Modelica export""" 

2import codecs 

3import logging 

4import os 

5from pathlib import Path 

6from threading import Lock 

7from typing import Union, Type, Dict, Container, Callable, List, Any, Iterable 

8 

9import numpy as np 

10import pint 

11from mako.template import Template 

12 

13import bim2sim 

14from bim2sim.elements import base_elements as elem 

15from bim2sim.elements.base_elements import Element 

16from bim2sim.elements.hvac_elements import HVACProduct, HVACPort 

17from bim2sim.kernel import log 

18from bim2sim.kernel.decision import DecisionBunch, RealDecision 

19 

20TEMPLATEPATH = (Path(bim2sim.__file__).parent / 

21 'assets/templates/modelica/tmplModel.txt') 

22# prevent mako newline bug by reading file separately 

23with open(TEMPLATEPATH) as f: 

24 templateStr = f.read() 

25template = Template(templateStr) 

26lock = Lock() 

27 

28logger = logging.getLogger(__name__) 

29user_logger = log.get_user_logger(__name__) 

30 

31 

32class ModelError(Exception): 

33 """Error occurring in model""" 

34 

35 

36class FactoryError(Exception): 

37 """Error in Model factory""" 

38 

39 

40def clean_string(string: str) -> str: 

41 """Replace modelica invalid chars by underscore.""" 

42 return string.replace('$', '_') 

43 

44 

45class ModelicaModel: 

46 """Modelica model""" 

47 

48 def __init__(self, 

49 name: str, 

50 comment: str, 

51 modelica_elements: List['ModelicaElement'], 

52 connections: list): 

53 """ 

54 Args: 

55 name: The name of the model. 

56 comment: A comment or description of the model. 

57 modelica_elements: A list of modelica elements in the model. 

58 connections: A list of connections between elements in the model. 

59 """ 

60 self.name = name 

61 self.comment = comment 

62 self.modelica_elements = modelica_elements 

63 

64 self.size_x = (-100, 100) 

65 self.size_y = (-100, 100) 

66 

67 self.connections = self.set_positions(modelica_elements, connections) 

68 

69 def set_positions(self, elements: list, connections: list) -> list: 

70 """ Sets the position of elements relative to min/max positions of 

71 instance.element.position 

72 

73 Args: 

74 elements: A list of elements whose positions are to be set. 

75 connections: A list of connections between the elements. 

76 

77 Returns: 

78 A list of connections with positions. 

79 """ 

80 instance_dict = {} 

81 connections_positions = [] 

82 

83 # Calculate the instance position 

84 positions = np.array( 

85 [inst.element.position if inst.element.position is not None else 

86 (0, 0) for inst in elements]) 

87 pos_min = np.min(positions, axis=0) 

88 pos_max = np.max(positions, axis=0) 

89 pos_delta = pos_max - pos_min 

90 delta_x = self.size_x[1] - self.size_x[0] 

91 delta_y = self.size_y[1] - self.size_y[0] 

92 for inst in elements: 

93 if inst.element.position is not None: 

94 rel_pos = (inst.element.position - pos_min) / pos_delta 

95 x = (self.size_x[0] + rel_pos[0] * delta_x).item() 

96 y = (self.size_y[0] + rel_pos[1] * delta_y).item() 

97 inst.position = (x, y) 

98 instance_dict[inst.name] = inst.position 

99 else: 

100 instance_dict[inst.name] = (0, 0) 

101 

102 # Add positions to connections 

103 for inst0, inst1 in connections: 

104 name0 = inst0.split('.')[0] 

105 name1 = inst1.split('.')[0] 

106 connections_positions.append( 

107 (inst0, inst1, instance_dict[name0], instance_dict[name1]) 

108 ) 

109 return connections_positions 

110 

111 def code(self) -> str: 

112 """ Returns the Modelica code for the model.The mako template is used to 

113 render the Modelica code based on the model's elements, connections, 

114 and unknown parameters. 

115 

116 Returns 

117 str: The Modelica code representation of the model. 

118 """ 

119 with lock: 

120 return template.render(model=self, unknowns=self.unknown_params()) 

121 

122 def unknown_params(self) -> list: 

123 """ Identifies unknown parameters in the model. Unknown parameters are 

124 parameters with None value and that are required by the model. 

125 

126 Returns: 

127 A list of unknown parameters in the model. 

128 """ 

129 unknown_parameters = [] 

130 for modelica_element in self.modelica_elements: 

131 unknown_parameter = [f'{modelica_element.name}.{parameter.name}' 

132 for parameter in 

133 modelica_element.parameters.values() 

134 if parameter.value is None 

135 and parameter.required is True] 

136 unknown_parameters.extend(unknown_parameter) 

137 return unknown_parameters 

138 

139 def save(self, path: str): 

140 """ Save the model as Modelica file. 

141 

142 Args: 

143 path (str): The path where the Modelica file should be saved. 

144 """ 

145 _path = os.path.normpath(path) 

146 if os.path.isdir(_path): 

147 _path = os.path.join(_path, self.name) 

148 

149 if not _path.endswith(".mo"): 

150 _path += ".mo" 

151 

152 data = self.code() 

153 

154 user_logger.info("Saving '%s' to '%s'", self.name, _path) 

155 with codecs.open(_path, "w", "utf-8") as file: 

156 file.write(data) 

157 

158 

159class ModelicaElement: 

160 """ Modelica model element 

161 

162 This class represents an element of a Modelica model, which includes 

163 elements, parameters, connections, and other metadata. 

164 

165 Attributes: 

166 library: The library the instance belongs to. 

167 version: The version of the library. 

168 path: The path of the model in the library. 

169 represents: The element or a container of elements that the instance 

170 represents. 

171 lookup: A dictionary mapping element types to instance types. 

172 dummy: A placeholder for an instance. 

173 _initialized: Indicates whether the instance has been initialized. 

174 

175 # TODO describe the total process 

176 

177 """ 

178 

179 library: str = None 

180 version = None 

181 path: str = None 

182 represents: Union[Element, Container[Element]] = None 

183 lookup: Dict[Type[Element], Type['ModelicaElement']] = {} 

184 dummy: Type['ModelicaElement'] = None 

185 _initialized = False 

186 

187 def __init__(self, element: HVACProduct): 

188 """ Initializes an Instance with the given HVACProduct element. 

189 

190 Args: 

191 element (HVACProduct): The HVACProduct element represented by the 

192 instance. 

193 """ 

194 self.element = element 

195 self.position = (80, 80) 

196 

197 self.parameters = {} 

198 self.connections = [] 

199 

200 self.guid = self._get_clean_guid() 

201 self.name = self._get_name() 

202 self.comment = self.get_comment() 

203 

204 def _get_clean_guid(self) -> str: 

205 """ Gets a clean GUID of the element. 

206 

207 Returns: 

208 The cleaned GUID of the element. 

209 """ 

210 return clean_string(getattr(self.element, "guid", "")) 

211 

212 def _get_name(self) -> str: 

213 """ Generates and returns a name for the instance based on the element's 

214 class name and GUID. 

215 

216 Returns: 

217 The generated name for the instance. 

218 """ 

219 name = self.element.__class__.__name__.lower() 

220 if self.guid: 

221 name = name + "_" + self.guid 

222 return name 

223 

224 @staticmethod 

225 def _lookup_add(key, value) -> bool: 

226 """ Adds a key-value pair to the Instance lookup dictionary. Logs a 

227 warning if there is a conflict. 

228 

229 Args: 

230 key: The key to add to the lookup dictionary. 

231 value: The value to associate with the key. 

232 

233 Returns: 

234 bool: False, indicating no conflict. 

235 """ 

236 """Adds key and value to Instance.lookup. Returns conflict""" 

237 if key in ModelicaElement.lookup and value is not ModelicaElement.lookup[key]: 

238 logger.warning("Conflicting representations (%s) in '%s' and '%s. " 

239 "Taking the more recent representation of library " 

240 "'%s'", 

241 key, 

242 value.__name__, 

243 ModelicaElement.lookup[key].__name__, 

244 value.library) 

245 ModelicaElement.lookup[key] = value 

246 return False 

247 

248 @staticmethod 

249 def init_factory(libraries: tuple): 

250 """ Initializes the lookup dictionary for the factory with the provided 

251 libraries. 

252 

253 Args: 

254 libraries: A tuple of libraries to initialize the factory with. 

255 

256 Raises: 

257 AssertionError: If a library is not defined or if there are 

258 conflicts in models. 

259 """ 

260 conflict = False 

261 ModelicaElement.dummy = Dummy 

262 for library in libraries: 

263 if ModelicaElement not in library.__bases__: 

264 logger.warning( 

265 "Got Library not directly inheriting from Instance.") 

266 if library.library: 

267 logger.info("Got library '%s'", library.library) 

268 else: 

269 logger.error("Attribute library not set for '%s'", 

270 library.__name__) 

271 raise AssertionError("Library not defined") 

272 for cls in library.__subclasses__(): 

273 if cls.represents is None: 

274 logger.warning("'%s' represents no model and can't be used", 

275 cls.__name__) 

276 continue 

277 

278 if isinstance(cls.represents, Container): 

279 for rep in cls.represents: 

280 confl = ModelicaElement._lookup_add(rep, cls) 

281 if confl: 

282 conflict = True 

283 else: 

284 confl = ModelicaElement._lookup_add(cls.represents, cls) 

285 if confl: 

286 conflict = True 

287 

288 if conflict: 

289 raise AssertionError( 

290 "Conflict(s) in Models. (See log for details).") 

291 

292 ModelicaElement._initialized = True 

293 

294 models = set(ModelicaElement.lookup.values()) 

295 models_txt = "\n".join( 

296 sorted([" - %s" % (inst.path) for inst in models])) 

297 logger.debug("Modelica libraries initialized with %d models:\n%s", 

298 len(models), models_txt) 

299 

300 @staticmethod 

301 def factory(element: HVACProduct): 

302 """Create model depending on ifc_element""" 

303 

304 if not ModelicaElement._initialized: 

305 raise FactoryError("Factory not initialized.") 

306 

307 cls = ModelicaElement.lookup.get(element.__class__, ModelicaElement.dummy) 

308 return cls(element) 

309 

310 def _set_parameter(self, name, unit, required, **kwargs): 

311 """ Sets a parameter for the instance. 

312 

313 Args: 

314 name: The name of the parameter as in the Modelica model. 

315 unit: The unit of the parameter as in the Modelica model. 

316 required: Whether the parameter is required. Raises a decision if a 

317 required parameter is not available 

318 **kwargs: Additional keyword arguments. 

319 """ 

320 self.parameters[name] = ModelicaParameter(name, unit, required, 

321 self.element, **kwargs) 

322 

323 def collect_params(self): 

324 """ Collects the parameters of the instance.""" 

325 for parameter in self.parameters.values(): 

326 parameter.collect() 

327 

328 @property 

329 def modelica_parameters(self) -> dict: 

330 """ Converts and returns the instance parameters to Modelica parameters. 

331 

332 Returns: 

333 A dictionary of Modelica parameters with key as name and value as 

334 the parameter in Modelica code. 

335 """ 

336 mp = {name: parameter.to_modelica() 

337 for name, parameter in self.parameters.items() 

338 if parameter.export} 

339 return mp 

340 

341 def get_comment(self) -> str: 

342 """ Returns comment string""" 

343 return self.element.source_info() 

344 

345 @property 

346 def path(self): 

347 """ Returns the model path in the library""" 

348 return self.__class__.path 

349 

350 def get_port_name(self, port: HVACPort) -> str: 

351 """ Get the name of port. Override this method in a subclass. 

352 

353 Args: 

354 port: The HVACPort for which to get the name. 

355 

356 Returns: 

357 The name of the port as string. 

358 """ 

359 return "port_unknown" 

360 

361 def get_full_port_name(self, port: HVACPort) -> str: 

362 """ Returns name of port including model name. 

363 

364 Args: 

365 port: The HVACPort for which to get the full name. 

366 

367 Returns: 

368 The full name of the port as string. 

369 """ 

370 return "%s.%s" % (self.name, self.get_port_name(port)) 

371 

372 def __repr__(self): 

373 return "<%s %s>" % (self.path, self.name) 

374 

375 

376class ModelicaParameter: 

377 """ Represents a parameter in a Modelica model. 

378 

379 Attributes: 

380 _decisions: Collection of decisions related to parameters. 

381 _answers: Dictionary to store answers for parameter decisions. 

382 """ 

383 _decisions = DecisionBunch() 

384 _answers: dict = {} 

385 

386 def __init__(self, name: str, unit: pint.Unit, required: bool, 

387 element: HVACProduct, **kwargs): 

388 """ 

389 Args: 

390 name: The name of the parameter as in the modelica model. 

391 unit: The unit of the parameter as in the modelica model. 

392 required: Indicates whether the parameter is required. Raises a 

393 decision if parameter is not available. 

394 element: The element to which the parameter belongs. 

395 **kwargs: Additional keyword arguments: 

396 check: A function to check the validity of the parameter value. 

397 export: Whether to export the parameter. Default is True. 

398 attributes: Element attributes related to the parameter. 

399 function: Function to compute the parameter value. 

400 value: Value of the parameter for direct allocation. 

401 """ 

402 self.name: str = name 

403 self.unit: pint.Unit = unit 

404 self.required: bool = required 

405 self.element: Element = element 

406 self.check: Callable = kwargs.get('check') 

407 self.export: bool = kwargs.get('export', True) 

408 self.attributes: Union[List[str], str] = kwargs.get('attributes', []) 

409 self.function: Callable = kwargs.get('function') 

410 self._function_inputs: list = kwargs.get('function_inputs', []) 

411 self._value: Any = kwargs.get('value') 

412 self._function_inputs: list = [] 

413 self.register() 

414 

415 def register(self): 

416 """ Registers the parameter, requesting necessary element attributes or 

417 creating decisions if necessary. 

418 

419 This method performs the following steps: 

420 1. If the parameter is required and does not have a function assigned: 

421 - Requests the specified attributes from the element. 

422 - If no attributes are specified, creates a decision for the 

423 parameter. 

424 2. If the parameter has a function assigned: 

425 - Processes the function inputs, which can be either 

426 ModelicaParameter instances or element attributes. 

427 - Raises an AttributeError if the function input is neither an 

428 attribute nor a ModelicaParameter. 

429 """ 

430 if self.required and not self.function: 

431 if self.attributes: 

432 for attribute in self.attributes: 

433 self.element.request(attribute) 

434 else: 

435 self._decisions.append( 

436 self._create_parameter_decision(self.name, self.unit)) 

437 elif self.function: 

438 function_inputs = self.function.__code__.co_varnames 

439 for function_input in function_inputs: 

440 if function_input in self.element.attributes: 

441 self.attributes.append(function_input) 

442 self.element.request(str(function_input)) 

443 else: 

444 self._function_inputs.append(function_input) 

445 

446 def _create_parameter_decision(self, 

447 name: str, 

448 unit: pint.Unit) -> RealDecision: 

449 """ Creates a decision for the parameter. 

450 

451 Args: 

452 name: The name of the parameter. 

453 unit: The unit of the parameter. 

454 

455 Returns: 

456 The decision object for the parameter. 

457 """ 

458 decision = RealDecision( 

459 question="Enter value for %s of %s" % (name, self.element), 

460 console_identifier="Name: %s, GUID: %s" 

461 % (self.name, self.element.guid), 

462 key=name, 

463 global_key=self.element.guid, 

464 allow_skip=False, 

465 unit=unit) 

466 return decision 

467 

468 @classmethod 

469 def get_pending_parameter_decisions(cls): 

470 """ Yields pending parameter decisions. 

471 

472 Yields: 

473 The decisions related to the parameters. 

474 """ 

475 decisions = cls._decisions 

476 decisions.sort(key=lambda d: d.key) 

477 yield decisions 

478 cls._answers.update(decisions.to_answer_dict()) 

479 

480 def collect(self): 

481 """ Collects the value of the parameter based on its source. 

482 

483 This method performs the following steps: 

484 1. If the parameter has a function assigned: 

485 - Collects all function inputs, either as ModelicaParameter values 

486 or attribute values. 

487 - Calls the function with the collected inputs and converts the 

488 function output to the parameter's value. 

489 2. If the parameter is required and has no attributes: 

490 - Sets the parameter value from the collected answers. 

491 3. If the parameter has attributes: 

492 - Retrieves the attribute value(s) and converts them to the 

493 parameter's value. 

494 4. If the parameter already has a value, it retains the existing value. 

495 5. If none of the above conditions are met, sets the parameter value to 

496 None and logs a warning. 

497 """ 

498 if self.function: 

499 if self.attributes: 

500 if len(self.attributes) > 1: 

501 function_output = self.function(*self.get_attribute_value()) 

502 else: 

503 function_output = self.function(self.get_attribute_value()) 

504 else: 

505 function_output = self.function(*self._function_inputs) 

506 self.value = self.convert_parameter(function_output) 

507 elif self.required and not self.attributes: 

508 self.value = self._answers[self.name] 

509 elif self.attributes: 

510 attribute_value = self.get_attribute_value() 

511 self.value = self.convert_parameter(attribute_value) 

512 elif self.value is not None: 

513 self.value = self.convert_parameter(self.value) 

514 else: 

515 self.value = None 

516 logger.warning(f'Parameter {self.name} could not be collected.') 

517 

518 @property 

519 def value(self): 

520 """Returns the current value of the parameter.""" 

521 return self._value 

522 

523 @value.setter 

524 def value(self, value): 

525 """ Sets the value of the parameter after validation if a check function 

526 is provided. 

527 

528 Args: 

529 value: The new value for the parameter. 

530 """ 

531 if self.check: 

532 if self.check(value): 

533 self._value = value 

534 else: 

535 logger.warning("Parameter check failed for '%s' with value: " 

536 "%s", self.name, self._value) 

537 self._value = None 

538 else: 

539 self._value = value 

540 

541 def get_attribute_value(self) \ 

542 -> Union[List[pint.Quantity], pint.Quantity]: 

543 """ Retrieves the value(s) of the parameter's attributes from the 

544 associated element. 

545 

546 Returns: 

547 The attribute value(s) as a list of `pint.Quantity` objects if there 

548 are multiple attributes, or a single `pint.Quantity` object if there 

549 is only one attribute. 

550 """ 

551 attribute_value = [getattr(self.element, attribute) 

552 for attribute in self.attributes] 

553 if len(attribute_value) > 1: 

554 return attribute_value 

555 else: 

556 return attribute_value[0] 

557 

558 def convert_parameter(self, parameter: Union[pint.Quantity, list]) \ 

559 -> Union[pint.Quantity, list]: 

560 """ Converts a parameter to its appropriate unit. 

561 

562 Args: 

563 parameter: The parameter to convert. 

564 

565 Returns: 

566 The converted parameter. 

567 """ 

568 if not self.unit: 

569 return parameter 

570 elif isinstance(parameter, pint.Quantity): 

571 return parameter.to(self.unit) 

572 elif isinstance(parameter, Iterable): 

573 return [self.convert_parameter(param) for param in parameter] 

574 

575 def to_modelica(self): 

576 return parse_to_modelica(self.name, self.value) 

577 

578 def __repr__(self): 

579 return f"{self.name}={self.value}" 

580 

581 

582def parse_to_modelica(name: Union[str, None], value: Any) -> Union[str, None]: 

583 """ Converts a parameter to a Modelica-readable string. 

584 

585 Args: 

586 name: The name of the parameter. 

587 value: The value of the parameter. 

588 

589 Returns: 

590 The Modelica-readable string representation of the parameter. 

591 

592 The conversion handles different data types as follows: 

593 - bool: Converted to "true" or "false". 

594 - ModelicaParameter: Recursively converts the parameter's name and value. 

595 - pint.Quantity: Converts the magnitude of the quantity. 

596 - int, float, str: Directly converted to their string representation. 

597 - list, tuple, set: Converted to a comma-separated list enclosed in curly 

598 braces. 

599 - dict: Converted to a Modelica record format, with each key-value pair 

600 converted recursively. 

601 - Path: Converts to a Modelica file resource load function call. 

602 - Other types: Logs a warning and converts to a string representation. 

603 """ 

604 if name: 

605 prefix = f'{name}=' 

606 else: 

607 prefix = '' 

608 if value is None: 

609 return value 

610 elif isinstance(value, bool): 

611 return f'{prefix}{str(value).lower()}' 

612 elif isinstance(value, ModelicaParameter): 

613 return parse_to_modelica(value.name, value.value) 

614 elif isinstance(value, ModelicaParameter): 

615 return parse_to_modelica(value.name, value.value) 

616 elif isinstance(value,pint.Quantity): 

617 return parse_to_modelica(name, value.magnitude) 

618 elif isinstance(value, (int, float)): 

619 return f'{prefix}{str(value)}' 

620 elif isinstance(value, str): 

621 return f'{prefix}{value}' 

622 elif isinstance(value, (list, tuple, set)): 

623 if any(x is None for x in value): 

624 return None 

625 else: 

626 return (prefix + "{%s}" 

627 % (",".join((parse_to_modelica(None, par) 

628 for par in value)))) 

629 # Handle modelica records 

630 elif isinstance(value, dict): 

631 record_str = f'{name}(' 

632 for index, (var_name, var_value) in enumerate(value.items(), 1): 

633 record_str += parse_to_modelica(var_name, 

634 var_value) 

635 if index < len(value): 

636 record_str += ',' 

637 else: 

638 record_str += ')' 

639 return record_str 

640 elif isinstance(value, Path): 

641 return \ 

642 (f"Modelica.Utilities.Files.loadResource(\"{str(value)}\")" 

643 .replace("\\", "\\\\")) 

644 logger.warning("Unknown class (%s) for conversion", value.__class__) 

645 return str(value) 

646 

647 

648def check_numeric(min_value: Union[pint.Quantity, None] = None, 

649 max_value: Union[pint.Quantity, None] = None): 

650 """ Generates a function to check if a given value falls within specified 

651 numeric bounds. 

652 

653 This function creates and returns a checker function (`inner_check`) that 

654 validates whether a given `value` (a `pint.Quantity`) falls within the range 

655 defined by `min_value` and `max_value`. 

656 

657 Args: 

658 min_value: The minimum value for the range check. 

659 max_value: The maximum value for the range check. 

660 

661 Raises: 

662 AssertionError: If `min_value` or `max_value` is not a `pint.Quantity` 

663 or `None`. 

664 

665 Returns: 

666 A function (`inner_check`) that takes a single argument value` and 

667 returns `True` if the value is within the specified bounds, 

668 otherwise `False`. 

669 """ 

670 if not isinstance(min_value, (pint.Quantity, type(None))): 

671 raise AssertionError("min_value is no pint quantity with unit") 

672 if not isinstance(max_value, (pint.Quantity, type(None))): 

673 raise AssertionError("max_value is no pint quantity with unit") 

674 

675 def inner_check(value): 

676 if not isinstance(value, pint.Quantity): 

677 return False 

678 if min_value is None and max_value is None: 

679 return True 

680 if min_value is not None and max_value is None: 

681 return min_value <= value 

682 if max_value is not None: 

683 return value <= max_value 

684 return min_value <= value <= max_value 

685 

686 return inner_check 

687 

688 

689def check_none(): 

690 """ Generates a function to check if a given value is not None.""" 

691 

692 def inner_check(value): 

693 return not isinstance(value, type(None)) 

694 

695 return inner_check 

696 

697 

698class Dummy(ModelicaElement): 

699 path = "Path.to.Dummy" 

700 represents = elem.Dummy