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
« 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
9import numpy as np
10import pint
11from mako.template import Template
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
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()
28logger = logging.getLogger(__name__)
29user_logger = log.get_user_logger(__name__)
32class ModelError(Exception):
33 """Error occurring in model"""
36class FactoryError(Exception):
37 """Error in Model factory"""
40def clean_string(string: str) -> str:
41 """Replace modelica invalid chars by underscore."""
42 return string.replace('$', '_')
45class ModelicaModel:
46 """Modelica model"""
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
64 self.size_x = (-100, 100)
65 self.size_y = (-100, 100)
67 self.connections = self.set_positions(modelica_elements, connections)
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
73 Args:
74 elements: A list of elements whose positions are to be set.
75 connections: A list of connections between the elements.
77 Returns:
78 A list of connections with positions.
79 """
80 instance_dict = {}
81 connections_positions = []
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)
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
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.
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())
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.
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
139 def save(self, path: str):
140 """ Save the model as Modelica file.
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)
149 if not _path.endswith(".mo"):
150 _path += ".mo"
152 data = self.code()
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)
159class ModelicaElement:
160 """ Modelica model element
162 This class represents an element of a Modelica model, which includes
163 elements, parameters, connections, and other metadata.
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.
175 # TODO describe the total process
177 """
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
187 def __init__(self, element: HVACProduct):
188 """ Initializes an Instance with the given HVACProduct element.
190 Args:
191 element (HVACProduct): The HVACProduct element represented by the
192 instance.
193 """
194 self.element = element
195 self.position = (80, 80)
197 self.parameters = {}
198 self.connections = []
200 self.guid = self._get_clean_guid()
201 self.name = self._get_name()
202 self.comment = self.get_comment()
204 def _get_clean_guid(self) -> str:
205 """ Gets a clean GUID of the element.
207 Returns:
208 The cleaned GUID of the element.
209 """
210 return clean_string(getattr(self.element, "guid", ""))
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.
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
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.
229 Args:
230 key: The key to add to the lookup dictionary.
231 value: The value to associate with the key.
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
248 @staticmethod
249 def init_factory(libraries: tuple):
250 """ Initializes the lookup dictionary for the factory with the provided
251 libraries.
253 Args:
254 libraries: A tuple of libraries to initialize the factory with.
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
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
288 if conflict:
289 raise AssertionError(
290 "Conflict(s) in Models. (See log for details).")
292 ModelicaElement._initialized = True
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)
300 @staticmethod
301 def factory(element: HVACProduct):
302 """Create model depending on ifc_element"""
304 if not ModelicaElement._initialized:
305 raise FactoryError("Factory not initialized.")
307 cls = ModelicaElement.lookup.get(element.__class__, ModelicaElement.dummy)
308 return cls(element)
310 def _set_parameter(self, name, unit, required, **kwargs):
311 """ Sets a parameter for the instance.
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)
323 def collect_params(self):
324 """ Collects the parameters of the instance."""
325 for parameter in self.parameters.values():
326 parameter.collect()
328 @property
329 def modelica_parameters(self) -> dict:
330 """ Converts and returns the instance parameters to Modelica parameters.
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
341 def get_comment(self) -> str:
342 """ Returns comment string"""
343 return self.element.source_info()
345 @property
346 def path(self):
347 """ Returns the model path in the library"""
348 return self.__class__.path
350 def get_port_name(self, port: HVACPort) -> str:
351 """ Get the name of port. Override this method in a subclass.
353 Args:
354 port: The HVACPort for which to get the name.
356 Returns:
357 The name of the port as string.
358 """
359 return "port_unknown"
361 def get_full_port_name(self, port: HVACPort) -> str:
362 """ Returns name of port including model name.
364 Args:
365 port: The HVACPort for which to get the full name.
367 Returns:
368 The full name of the port as string.
369 """
370 return "%s.%s" % (self.name, self.get_port_name(port))
372 def __repr__(self):
373 return "<%s %s>" % (self.path, self.name)
376class ModelicaParameter:
377 """ Represents a parameter in a Modelica model.
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 = {}
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()
415 def register(self):
416 """ Registers the parameter, requesting necessary element attributes or
417 creating decisions if necessary.
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)
446 def _create_parameter_decision(self,
447 name: str,
448 unit: pint.Unit) -> RealDecision:
449 """ Creates a decision for the parameter.
451 Args:
452 name: The name of the parameter.
453 unit: The unit of the parameter.
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
468 @classmethod
469 def get_pending_parameter_decisions(cls):
470 """ Yields pending parameter decisions.
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())
480 def collect(self):
481 """ Collects the value of the parameter based on its source.
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.')
518 @property
519 def value(self):
520 """Returns the current value of the parameter."""
521 return self._value
523 @value.setter
524 def value(self, value):
525 """ Sets the value of the parameter after validation if a check function
526 is provided.
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
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.
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]
558 def convert_parameter(self, parameter: Union[pint.Quantity, list]) \
559 -> Union[pint.Quantity, list]:
560 """ Converts a parameter to its appropriate unit.
562 Args:
563 parameter: The parameter to convert.
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]
575 def to_modelica(self):
576 return parse_to_modelica(self.name, self.value)
578 def __repr__(self):
579 return f"{self.name}={self.value}"
582def parse_to_modelica(name: Union[str, None], value: Any) -> Union[str, None]:
583 """ Converts a parameter to a Modelica-readable string.
585 Args:
586 name: The name of the parameter.
587 value: The value of the parameter.
589 Returns:
590 The Modelica-readable string representation of the parameter.
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)
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.
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`.
657 Args:
658 min_value: The minimum value for the range check.
659 max_value: The maximum value for the range check.
661 Raises:
662 AssertionError: If `min_value` or `max_value` is not a `pint.Quantity`
663 or `None`.
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")
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
686 return inner_check
689def check_none():
690 """ Generates a function to check if a given value is not None."""
692 def inner_check(value):
693 return not isinstance(value, type(None))
695 return inner_check
698class Dummy(ModelicaElement):
699 path = "Path.to.Dummy"
700 represents = elem.Dummy