exerpy 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. exerpy/__init__.py +12 -0
  2. exerpy/analyses.py +1711 -0
  3. exerpy/components/__init__.py +16 -0
  4. exerpy/components/combustion/__init__.py +0 -0
  5. exerpy/components/combustion/base.py +248 -0
  6. exerpy/components/component.py +126 -0
  7. exerpy/components/heat_exchanger/__init__.py +0 -0
  8. exerpy/components/heat_exchanger/base.py +449 -0
  9. exerpy/components/heat_exchanger/condenser.py +323 -0
  10. exerpy/components/heat_exchanger/simple.py +358 -0
  11. exerpy/components/heat_exchanger/steam_generator.py +264 -0
  12. exerpy/components/helpers/__init__.py +0 -0
  13. exerpy/components/helpers/cycle_closer.py +104 -0
  14. exerpy/components/nodes/__init__.py +0 -0
  15. exerpy/components/nodes/deaerator.py +318 -0
  16. exerpy/components/nodes/drum.py +164 -0
  17. exerpy/components/nodes/flash_tank.py +89 -0
  18. exerpy/components/nodes/mixer.py +332 -0
  19. exerpy/components/piping/__init__.py +0 -0
  20. exerpy/components/piping/valve.py +394 -0
  21. exerpy/components/power_machines/__init__.py +0 -0
  22. exerpy/components/power_machines/generator.py +168 -0
  23. exerpy/components/power_machines/motor.py +173 -0
  24. exerpy/components/turbomachinery/__init__.py +0 -0
  25. exerpy/components/turbomachinery/compressor.py +318 -0
  26. exerpy/components/turbomachinery/pump.py +310 -0
  27. exerpy/components/turbomachinery/turbine.py +351 -0
  28. exerpy/data/Ahrendts.json +90 -0
  29. exerpy/functions.py +637 -0
  30. exerpy/parser/__init__.py +0 -0
  31. exerpy/parser/from_aspen/__init__.py +0 -0
  32. exerpy/parser/from_aspen/aspen_config.py +61 -0
  33. exerpy/parser/from_aspen/aspen_parser.py +721 -0
  34. exerpy/parser/from_ebsilon/__init__.py +38 -0
  35. exerpy/parser/from_ebsilon/check_ebs_path.py +74 -0
  36. exerpy/parser/from_ebsilon/ebsilon_config.py +1055 -0
  37. exerpy/parser/from_ebsilon/ebsilon_functions.py +181 -0
  38. exerpy/parser/from_ebsilon/ebsilon_parser.py +660 -0
  39. exerpy/parser/from_ebsilon/utils.py +79 -0
  40. exerpy/parser/from_tespy/tespy_config.py +23 -0
  41. exerpy-0.0.1.dist-info/METADATA +158 -0
  42. exerpy-0.0.1.dist-info/RECORD +44 -0
  43. exerpy-0.0.1.dist-info/WHEEL +4 -0
  44. exerpy-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,660 @@
1
+ """
2
+ Ebsilon Model Parser
3
+
4
+ This module defines the EbsilonModelParser class, which is used to parse Ebsilon models,
5
+ simulate them, extract data about components and connections, and write the data to a JSON file.
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ from typing import Any
11
+ from typing import Dict
12
+ from typing import Optional
13
+
14
+ from exerpy.functions import convert_to_SI
15
+ from exerpy.functions import fluid_property_data
16
+
17
+ from . import __ebsilon_available__
18
+ from . import is_ebsilon_available
19
+ from .utils import EpCalculationResultStatus2Stub
20
+ from .utils import EpFluidTypeStub
21
+ from .utils import EpGasTableStub
22
+ from .utils import EpSteamTableStub
23
+ from .utils import require_ebsilon
24
+
25
+ # Import Ebsilon classes if available
26
+ if __ebsilon_available__:
27
+ from EbsOpen import EpCalculationResultStatus2
28
+ from EbsOpen import EpFluidType
29
+ from EbsOpen import EpGasTable
30
+ from EbsOpen import EpSteamTable
31
+ from EbsOpen import EpSubstance
32
+ from win32com.client import Dispatch
33
+ else:
34
+ EpFluidType = EpFluidTypeStub
35
+ EpSteamTable = EpSteamTableStub
36
+ EpGasTable = EpGasTableStub
37
+ EpCalculationResultStatus2 = EpCalculationResultStatus2Stub
38
+
39
+ from .ebsilon_config import composition_params
40
+ from .ebsilon_config import connection_kinds
41
+ from .ebsilon_config import connector_mapping
42
+ from .ebsilon_config import ebs_objects
43
+ from .ebsilon_config import fluid_type_index
44
+ from .ebsilon_config import grouped_components
45
+ from .ebsilon_config import non_thermodynamic_unit_operators
46
+ from .ebsilon_config import two_phase_fluids_mapping
47
+ from .ebsilon_config import unit_id_to_string
48
+
49
+ # Configure logging to display info-level messages
50
+ logging.basicConfig(level=logging.ERROR)
51
+
52
+
53
+ class EbsilonModelParser:
54
+ """
55
+ A class to parse Ebsilon models, simulate them, extract data, and write to JSON.
56
+ """
57
+ def __init__(self, model_path: str, split_physical_exergy: bool = True):
58
+ """
59
+ Initializes the parser with the given model path.
60
+
61
+ Parameters:
62
+ model_path (str): Path to the Ebsilon model file.
63
+ split_physical_exergy (bool): Flag to split physical exergy into thermal and mechanical components.
64
+
65
+ Raises:
66
+ RuntimeError: If Ebsilon is not available but is required for parsing.
67
+ """
68
+ # Check if Ebsilon is available
69
+ if not is_ebsilon_available():
70
+ logging.warning(
71
+ "EbsilonModelParser initialized without Ebsilon support. "
72
+ "EBS environment variable is not set or EbsOpen could not be imported; "
73
+ "Ebsilon functionality will not be available."
74
+ )
75
+ # Raise an error since this parser specifically requires Ebsilon
76
+ raise RuntimeError(
77
+ "EbsilonModelParser requires Ebsilon to be available. "
78
+ "Please set the EBS environment variable to your Ebsilon installation path."
79
+ )
80
+
81
+ self.model_path = model_path
82
+ self.split_physical_exergy = split_physical_exergy
83
+ self.app = None # Ebsilon application instance
84
+ self.model = None # Opened Ebsilon model
85
+ self.oc = None # ObjectCaster for type casting
86
+ self.components_data: Dict[str, Dict[str, Dict[str, Any]]] = {} # Dictionary to store component data
87
+ self.connections_data: Dict[str, Dict[str, Any]] = {} # Dictionary to store connection data
88
+ self.Tamb: Optional[float] = None # Ambient temperature
89
+ self.pamb: Optional[float] = None # Ambient pressure
90
+
91
+ @require_ebsilon
92
+ def initialize_model(self):
93
+ """
94
+ Initializes the Ebsilon application and opens the specified model.
95
+
96
+ Raises:
97
+ FileNotFoundError: If the model file cannot be opened.
98
+ RuntimeError: If the COM server cannot be started or ObjectCaster cannot be obtained.
99
+ """
100
+ # 1) start the COM server
101
+ try:
102
+ self.app = Dispatch("EbsOpen.Application")
103
+ except Exception as e:
104
+ logging.error(f"Failed to start Ebsilon COM server: {e}")
105
+ raise RuntimeError(f"Could not start Ebsilon COM server: {e}")
106
+
107
+ # 2) try to open the .ebs model
108
+ try:
109
+ self.model = self.app.Open(self.model_path)
110
+ except Exception as e:
111
+ logging.error(f"Failed to open model file: {e}")
112
+ raise FileNotFoundError(f"File not found at: {self.model_path}") from e
113
+
114
+ # 3) grab the ObjectCaster
115
+ try:
116
+ self.oc = self.app.ObjectCaster
117
+ except Exception as e:
118
+ logging.error(f"Failed to obtain ObjectCaster: {e}")
119
+ raise RuntimeError(f"Could not get ObjectCaster: {e}")
120
+
121
+ logging.info(f"Model opened successfully: {self.model_path}")
122
+
123
+ @require_ebsilon
124
+ def simulate_model(self):
125
+ """
126
+ Simulates the Ebsilon model and logs any calculation errors.
127
+
128
+ Raises:
129
+ Exception: If model simulation fails.
130
+ """
131
+ try:
132
+ # Prepare to collect calculation errors
133
+ calc_errors = self.model.CalculationErrors
134
+ # Run the simulation
135
+ self.model.SimulateNew()
136
+ error_count = calc_errors.Count
137
+ logging.warning(f"Simulation has {error_count} warning(s).")
138
+ # Log each error if any exist
139
+ if error_count > 0:
140
+ for i in range(1, error_count + 1):
141
+ error = calc_errors.Item(i)
142
+ logging.warning(f"Warning {i}: {error.Description}")
143
+ except Exception as e:
144
+ logging.error(f"Failed during simulation: {e}")
145
+ raise
146
+
147
+ @require_ebsilon
148
+ def parse_model(self):
149
+ """
150
+ Parses all objects in the Ebsilon model to extract component and connection data.
151
+
152
+ Raises:
153
+ ValueError: If ambient conditions are not set.
154
+ Exception: If model parsing fails.
155
+ """
156
+ try:
157
+ total_objects = self.model.Objects.Count
158
+ logging.info(f"Parsing {total_objects} objects from the model")
159
+ # Iterate over all objects in the model and select the components
160
+ for j in range(1, total_objects + 1):
161
+ obj = self.model.Objects.Item(j)
162
+ # Check if the object is a component (epObjectKindComp = 10)
163
+ if obj.IsKindOf(10):
164
+ self.parse_component(obj)
165
+
166
+ # After parsing all components, check if Tamb and pamb have been set
167
+ if self.Tamb is None or self.pamb is None:
168
+ error_msg = (
169
+ "Ambient temperature (Tamb) and/or ambient pressure (pamb) have not been set.\n"
170
+ "Please ensure that your Ebsilon model includes component(s) of type 46 (Measuring Point) "
171
+ "with a setting for the Ambient Temperature and the Ambient Pressure in MEASM."
172
+ )
173
+ logging.error(error_msg)
174
+ raise ValueError(error_msg)
175
+
176
+ # Iterate over all objects in the model and select the connections
177
+ for j in range(1, total_objects + 1):
178
+ obj = self.model.Objects.Item(j)
179
+ # Check if the object is a pipe (epObjectKindPipe = 16)
180
+ if obj.IsKindOf(16):
181
+ self.parse_connection(obj)
182
+
183
+ except Exception as e:
184
+ logging.error(f"Error while parsing the model: {e}")
185
+ raise
186
+
187
+
188
+ @require_ebsilon
189
+ def parse_connection(self, obj: Any):
190
+ """
191
+ Parses the connections (pipes) associated with a component.
192
+
193
+ Parameters:
194
+ obj: The Ebsilon component object whose connections are to be parsed.
195
+ """
196
+ from .ebsilon_functions import calc_eM
197
+ from .ebsilon_functions import calc_eT
198
+
199
+ # Cast the pipe to the correct type
200
+ pipe_cast = self.oc.CastToPipe(obj)
201
+
202
+ # Define fluid types that are considered non-material or non-energetic
203
+ non_material_fluids = {5, 6, 9, 10, 13} # Scheduled, Actual, Electric, Shaft, Logic
204
+ non_energetic_fluids = {5, 6} # Scheduled, Actual
205
+ power_fluids = {9, 10} # Electric, Shaft
206
+ logic_fluids = 13 # Logic "fluids" for heat and power flows
207
+ heat_components = {5, 15, 16, 35} # Components that handle with heat flows as input or output
208
+ power_components = {31} # Power-summerized with power flows ONLY as output
209
+
210
+ # ALL EBSILON CONNECTIONS
211
+ # Initialize connection data with the common fields
212
+ connection_data = {
213
+ 'name': pipe_cast.Name,
214
+ 'kind': "other", # it will be changed later ("material", "heat", "power") according to the fluid type
215
+ 'source_component': None,
216
+ 'source_component_type': None,
217
+ 'source_connector': None,
218
+ 'target_component': None,
219
+ 'target_component_type': None,
220
+ 'target_connector': None,
221
+ 'fluid_type': fluid_type_index.get(pipe_cast.FluidType, "Unknown"),
222
+ 'fluid_type_id': pipe_cast.FluidType,
223
+ }
224
+
225
+ # Check if the connection is is not in non-energetic fluids
226
+ if (pipe_cast.Kind - 1000) not in non_energetic_fluids:
227
+ # Get the components at both ends of the pipe
228
+ comp0 = pipe_cast.Comp(0) if pipe_cast.HasComp(0) else None
229
+ comp1 = pipe_cast.Comp(1) if pipe_cast.HasComp(1) else None
230
+ # Get the connectors (links) at both ends of the pipe
231
+ link0 = pipe_cast.Link(0) if pipe_cast.HasComp(0) else None
232
+ link1 = pipe_cast.Link(1) if pipe_cast.HasComp(1) else None
233
+
234
+ # GENERAL INFORMATION
235
+ connection_data.update({
236
+ 'source_component': comp0.Name if comp0 else None,
237
+ 'source_component_type': (comp0.Kind - 10000) if comp0 else None,
238
+ 'source_connector': link0.Index if link0 else None,
239
+ 'target_component': comp1.Name if comp1 else None,
240
+ 'target_component_type': (comp1.Kind - 10000) if comp1 else None,
241
+ 'target_connector': link1.Index if link1 else None,
242
+ })
243
+
244
+ # MATERIAL CONNECTIONS
245
+ if (pipe_cast.Kind - 1000) not in non_material_fluids:
246
+ # Retrieve all data and convert them in SI units
247
+ connection_data.update({
248
+ 'kind': 'material',
249
+ 'm': (
250
+ convert_to_SI(
251
+ 'm',
252
+ pipe_cast.M.Value,
253
+ unit_id_to_string.get(pipe_cast.M.Dimension, "Unknown")
254
+ ) if hasattr(pipe_cast, 'M') and pipe_cast.M.Value is not None else None
255
+ ),
256
+ 'm_unit': fluid_property_data['m']['SI_unit'],
257
+
258
+ 'T': (
259
+ convert_to_SI(
260
+ 'T',
261
+ pipe_cast.T.Value,
262
+ unit_id_to_string.get(pipe_cast.T.Dimension, "Unknown")
263
+ ) if hasattr(pipe_cast, 'T') and pipe_cast.T.Value is not None else None
264
+ ),
265
+ 'T_unit': fluid_property_data['T']['SI_unit'],
266
+
267
+ 'p': (
268
+ convert_to_SI(
269
+ 'p',
270
+ pipe_cast.P.Value,
271
+ unit_id_to_string.get(pipe_cast.P.Dimension, "Unknown")
272
+ ) if hasattr(pipe_cast, 'P') and pipe_cast.P.Value is not None else None
273
+ ),
274
+ 'p_unit': fluid_property_data['p']['SI_unit'],
275
+
276
+ 'h': (
277
+ convert_to_SI(
278
+ 'h',
279
+ pipe_cast.H.Value,
280
+ unit_id_to_string.get(pipe_cast.H.Dimension, "Unknown")
281
+ ) if hasattr(pipe_cast, 'H') and pipe_cast.H.Value is not None else None
282
+ ),
283
+ 'h_unit': fluid_property_data['h']['SI_unit'],
284
+
285
+ 's': (
286
+ convert_to_SI(
287
+ 's',
288
+ pipe_cast.S.Value,
289
+ unit_id_to_string.get(pipe_cast.S.Dimension, "Unknown")
290
+ ) if hasattr(pipe_cast, 'S') and pipe_cast.S.Value is not None else None
291
+ ),
292
+ 's_unit': fluid_property_data['s']['SI_unit'],
293
+
294
+ 'e_PH': (
295
+ convert_to_SI(
296
+ 'e',
297
+ pipe_cast.E.Value,
298
+ unit_id_to_string.get(pipe_cast.E.Dimension, "Unknown")
299
+ ) if hasattr(pipe_cast, 'E') and pipe_cast.E.Value is not None else None
300
+ ),
301
+ 'e_PH_unit': fluid_property_data['e']['SI_unit'],
302
+
303
+ 'x': (
304
+ convert_to_SI(
305
+ 'x',
306
+ pipe_cast.X.Value,
307
+ unit_id_to_string.get(pipe_cast.X.Dimension, "Unknown")
308
+ ) if hasattr(pipe_cast, 'X') and pipe_cast.X.Value is not None else None
309
+ ),
310
+ 'x_unit': fluid_property_data['x']['SI_unit'],
311
+
312
+ 'VM': (
313
+ convert_to_SI(
314
+ 'VM',
315
+ pipe_cast.VM.Value,
316
+ unit_id_to_string.get(pipe_cast.VM.Dimension, "Unknown")
317
+ ) if hasattr(pipe_cast, 'VM') and pipe_cast.VM.Value is not None else None
318
+ ),
319
+ 'VM_unit': fluid_property_data['VM']['SI_unit'],
320
+ })
321
+
322
+ # Add the mechanical and thermal specific exergies unless the flag is set to False
323
+ if self.split_physical_exergy:
324
+ e_T_value = calc_eT(self.app, pipe_cast, connection_data['p'], self.Tamb, self.pamb)
325
+ e_M_value = calc_eM(self.app, pipe_cast, connection_data['p'], self.Tamb, self.pamb)
326
+
327
+ connection_data.update({
328
+ 'e_T': e_T_value,
329
+ 'e_T_unit': fluid_property_data['e']['SI_unit'],
330
+ 'e_M': e_M_value,
331
+ 'e_M_unit': fluid_property_data['e']['SI_unit']
332
+ })
333
+
334
+ # Handle mass composition logic for fluids
335
+ if fluid_type_index.get(pipe_cast.FluidType, "Unknown") in ['Steam', 'Water']:
336
+ connection_data['mass_composition'] = {'H2O': 1}
337
+ elif fluid_type_index.get(pipe_cast.FluidType, "Unknown") in ['2PhaseLiquid', '2PhaseGaseous']:
338
+ # Get the FMED value to determine the substance
339
+ fmed_value = pipe_cast.FMED.Value if hasattr(pipe_cast, 'FMED') else None
340
+ if fmed_value in two_phase_fluids_mapping.keys():
341
+ connection_data['mass_composition'] = two_phase_fluids_mapping[fmed_value]
342
+ else:
343
+ connection_data['mass_composition'] = {} # Default if no mapping found
344
+ logging.warning(f"FMED value {fmed_value} not found in fluid_composition_mapping. Please add it.")
345
+ else:
346
+ connection_data['mass_composition'] = {
347
+ param.lstrip('X'): getattr(pipe_cast, param).Value
348
+ for param in composition_params
349
+ if hasattr(pipe_cast, param) and getattr(pipe_cast, param).Value not in [0, None]
350
+ }
351
+
352
+ # HEAT AND POWER CONNECTIONS from Logic "fluids"
353
+ if (pipe_cast.Kind - 1000) == logic_fluids:
354
+ if (comp0 is not None and comp0.Kind is not None and comp0.Kind - 10000 in heat_components) or (comp1 is not None and comp1.Kind is not None and comp1.Kind - 10000 in heat_components):
355
+ connection_data.update({
356
+ 'kind': "heat",
357
+ 'energy_flow': convert_to_SI('heat', pipe_cast.Q.Value, unit_id_to_string.get(pipe_cast.Q.Dimension, "Unknown")) if hasattr(pipe_cast, 'Q') and pipe_cast.Q.Value is not None else None,
358
+ 'energy_flow_unit': fluid_property_data['heat']['SI_unit'],
359
+ 'E': None,
360
+ 'E_unit': fluid_property_data['power']['SI_unit'],
361
+ })
362
+ if (comp0 is not None and comp0.Kind is not None and comp0.Kind - 10000 in power_components):
363
+ connection_data.update({
364
+ 'kind': "power",
365
+ 'energy_flow': convert_to_SI('power', pipe_cast.Q.Value, unit_id_to_string.get(pipe_cast.Q.Dimension, "Unknown")) if hasattr(pipe_cast, 'Q') and pipe_cast.Q.Value is not None else None,
366
+ 'energy_flow_unit': fluid_property_data['power']['SI_unit'],
367
+ 'E': convert_to_SI('power', pipe_cast.Q.Value, unit_id_to_string.get(pipe_cast.Q.Dimension, "Unknown")) if hasattr(pipe_cast, 'Q') and pipe_cast.Q.Value is not None else None,
368
+ 'E_unit': fluid_property_data['power']['SI_unit'],
369
+ })
370
+
371
+ # POWER CONNECTIONS from power "fluids"
372
+ if (pipe_cast.Kind - 1000) in power_fluids:
373
+ connection_data.update({
374
+ 'kind': "power",
375
+ 'energy_flow': convert_to_SI('power', pipe_cast.Q.Value, unit_id_to_string.get(pipe_cast.Q.Dimension, "Unknown")) if hasattr(pipe_cast, 'Q') and pipe_cast.Q.Value is not None else None,
376
+ 'energy_flow_unit': fluid_property_data['power']['SI_unit'],
377
+ 'E': convert_to_SI('power', pipe_cast.Q.Value, unit_id_to_string.get(pipe_cast.Q.Dimension, "Unknown")) if hasattr(pipe_cast, 'Q') and pipe_cast.Q.Value is not None else None,
378
+ 'E_unit': fluid_property_data['power']['SI_unit'],
379
+ })
380
+
381
+ # Convert the connector numbers to selected standard values for each component
382
+ if connection_data['source_component_type'] in connector_mapping and connection_data['source_connector'] in connector_mapping[connection_data['source_component_type']]:
383
+ connection_data['source_connector'] = connector_mapping[connection_data['source_component_type']][connection_data['source_connector']]
384
+
385
+ if connection_data['target_component_type'] in connector_mapping and connection_data['target_connector'] in connector_mapping[connection_data['target_component_type']]:
386
+ connection_data['target_connector'] = connector_mapping[connection_data['target_component_type']][connection_data['target_connector']]
387
+
388
+ # Store the connection data
389
+ self.connections_data[obj.Name] = connection_data
390
+
391
+ else:
392
+ logging.info(f"Skipping non-energetic connection: {pipe_cast.Name}")
393
+
394
+
395
+ @require_ebsilon
396
+ def parse_component(self, obj: Any):
397
+ """
398
+ Parses data from a component, including its type and various properties.
399
+
400
+ Parameters:
401
+ obj: The Ebsilon component object to parse.
402
+ """
403
+ # Cast the component to get its type index
404
+ comp_cast = self.oc.CastToComp(obj)
405
+ type_index = (comp_cast.Kind - 10000)
406
+
407
+ # Dynamically call the specific CastToCompX method based on type_index
408
+ cast_method_name = f"CastToComp{type_index}"
409
+
410
+ # Check if the method exists and call it, otherwise fallback to general casting
411
+ if hasattr(self.oc, cast_method_name):
412
+ comp_cast = getattr(self.oc, cast_method_name)(obj)
413
+ logging.info(f"Using method {cast_method_name} to cast the component.")
414
+ else:
415
+ logging.warning(f"No specific cast method for type_index {type_index}, using generic CastToComp.")
416
+ comp_cast = self.oc.CastToComp(obj)
417
+
418
+ # Get the human-readable type name of the component
419
+ type_name = ebs_objects.get(type_index, f"Unknown Type {type_index}")
420
+
421
+ # Exclude non-thermodynamic unit operators
422
+ if type_index not in non_thermodynamic_unit_operators:
423
+ # Collect component data
424
+ component_data = {
425
+ 'name': comp_cast.Name,
426
+ 'type': type_name,
427
+ 'type_index': type_index,
428
+ 'eta_s': (
429
+ comp_cast.ETAIN.Value
430
+ if hasattr(comp_cast, 'ETAIN') and comp_cast.ETAIN.Value is not None else None
431
+ ),
432
+ 'eta_mech': (
433
+ comp_cast.ETAMN.Value
434
+ if hasattr(comp_cast, 'ETAMN') and comp_cast.ETAMN.Value is not None else None
435
+ ),
436
+ 'eta_el': (
437
+ comp_cast.ETAEN.Value
438
+ if hasattr(comp_cast, 'ETAEN') and comp_cast.ETAEN.Value is not None else None
439
+ ),
440
+ 'eta_cc': (
441
+ comp_cast.ETAB.Value
442
+ if hasattr(comp_cast, 'ETAB') and comp_cast.ETAB.Value is not None else None
443
+ ),
444
+ 'lamb': (
445
+ comp_cast.ALAMN.Value
446
+ if hasattr(comp_cast, 'ALAMN') and comp_cast.ALAMN.Value is not None else None
447
+ ),
448
+ 'Q': (
449
+ convert_to_SI(
450
+ 'heat',
451
+ comp_cast.QT.Value,
452
+ unit_id_to_string.get(comp_cast.QT.Dimension, "Unknown")
453
+ ) if hasattr(comp_cast, 'QT') and comp_cast.QT.Value is not None else None
454
+ ),
455
+ 'Q_unit': fluid_property_data['heat']['SI_unit'],
456
+ 'P': (
457
+ convert_to_SI(
458
+ 'power',
459
+ comp_cast.QSHAFT.Value,
460
+ unit_id_to_string.get(comp_cast.QSHAFT.Dimension, "Unknown")
461
+ ) if hasattr(comp_cast, 'QSHAFT') and comp_cast.QSHAFT.Value is not None else None
462
+ ),
463
+ 'P_unit': fluid_property_data['power']['SI_unit'],
464
+ 'kA': (
465
+ comp_cast.KA.Value
466
+ if hasattr(comp_cast, 'KA') and comp_cast.KA.Value is not None else None
467
+ ),
468
+ 'kA_unit': fluid_property_data['kA']['SI_unit'],
469
+ 'A': (
470
+ comp_cast.A.Value
471
+ if hasattr(comp_cast, 'A') and comp_cast.A.Value is not None else None
472
+ ),
473
+ 'A_unit': fluid_property_data['A']['SI_unit'],
474
+ 'mass_flow_1': (
475
+ convert_to_SI(
476
+ 'm',
477
+ comp_cast.M1N.Value,
478
+ unit_id_to_string.get(comp_cast.M1N.Dimension, "Unknown")
479
+ ) if hasattr(comp_cast, 'M1N') and comp_cast.M1N.Value is not None else None
480
+ ),
481
+ 'mass_flow_1_unit': fluid_property_data['m']['SI_unit'],
482
+ 'mass_flow_3': (
483
+ convert_to_SI(
484
+ 'm',
485
+ comp_cast.M3N.Value,
486
+ unit_id_to_string.get(comp_cast.M3N.Dimension, "Unknown")
487
+ ) if hasattr(comp_cast, 'M3N') and comp_cast.M3N.Value is not None else None
488
+ ),
489
+ 'mass_flow_3_unit': fluid_property_data['m']['SI_unit'],
490
+ 'energy_flow_1': (
491
+ convert_to_SI(
492
+ 'heat',
493
+ comp_cast.Q1N.Value,
494
+ unit_id_to_string.get(comp_cast.Q1N.Dimension, "Unknown")
495
+ ) if hasattr(comp_cast, 'Q1N') and comp_cast.Q1N.Value is not None else None
496
+ ),
497
+ 'mass_flow_1_unit': fluid_property_data['heat']['SI_unit'],
498
+ }
499
+
500
+ # Determine the group for the component based on its type
501
+ group = None
502
+ for group_name, type_list in grouped_components.items():
503
+ if type_index in type_list:
504
+ group = group_name
505
+ break
506
+
507
+ # If the component type doesn't belong to any predefined group, use its type name
508
+ if not group:
509
+ group = type_name
510
+
511
+ # Initialize the group in the components_data dictionary if not already present
512
+ if group not in self.components_data:
513
+ self.components_data[group] = {}
514
+
515
+ # Store the component data using the component's name as the key
516
+ self.components_data[group][comp_cast.Name] = component_data
517
+
518
+ # For components of type 46, set ambient temperature and pressure
519
+ elif type_index == 46:
520
+ comp46 = self.oc.CastToComp46(obj)
521
+ if comp46.FTYP.Value == 26:
522
+ self.Tamb = convert_to_SI('T', comp46.MEASM.Value, unit_id_to_string.get(comp46.MEASM.Dimension, "Unknown"))
523
+ logging.info(f"Set ambient temperature (Tamb) to {self.Tamb} K from component {comp_cast.Name}")
524
+ elif comp46.FTYP.Value == 13:
525
+ self.pamb = convert_to_SI('p', comp46.MEASM.Value, unit_id_to_string.get(comp46.MEASM.Dimension, "Unknown"))
526
+ logging.info(f"Set ambient pressure (pamb) to {self.pamb} Pa from component {comp_cast.Name}")
527
+
528
+
529
+ def get_sorted_data(self) -> Dict[str, Any]:
530
+ """
531
+ Sorts the component and connection data alphabetically by name.
532
+
533
+ Returns:
534
+ dict: A dictionary containing sorted 'components', 'connections', and ambient conditions data.
535
+ """
536
+ # Sort components within each group by component name
537
+ sorted_components = {
538
+ comp_type: dict(sorted(self.components_data[comp_type].items()))
539
+ for comp_type in sorted(self.components_data)
540
+ }
541
+ # Sort connections by their names
542
+ sorted_connections = dict(sorted(self.connections_data.items()))
543
+ # Return data including ambient conditions
544
+ return {
545
+ 'components': sorted_components,
546
+ 'connections': sorted_connections,
547
+ 'ambient_conditions': {
548
+ 'Tamb': self.Tamb,
549
+ 'Tamb_unit': fluid_property_data['T']['SI_unit'],
550
+ 'pamb': self.pamb,
551
+ 'pamb_unit': fluid_property_data['p']['SI_unit']
552
+ }
553
+ }
554
+
555
+
556
+ def write_to_json(self, output_path: str):
557
+ """
558
+ Writes the parsed and sorted data to a JSON file.
559
+
560
+ Parameters:
561
+ output_path (str): Path where the JSON file will be saved.
562
+
563
+ Raises:
564
+ Exception: If writing to JSON fails.
565
+ """
566
+ data = self.get_sorted_data()
567
+ try:
568
+ # Write the data to a JSON file with indentation for readability
569
+ with open(output_path, 'w') as json_file:
570
+ json.dump(data, json_file, indent=4)
571
+ logging.info(f"Data successfully written to {output_path}")
572
+ except Exception as e:
573
+ logging.error(f"Failed to write data to JSON: {e}")
574
+ raise
575
+
576
+
577
+ def run_ebsilon(model_path: str, output_dir: Optional[str] = None, split_physical_exergy: bool = True) -> Dict[str, Any]:
578
+ """
579
+ Main function to process the Ebsilon model and return parsed data.
580
+ Optionally writes the parsed data to a JSON file.
581
+
582
+ Parameters:
583
+ model_path (str): Path to the Ebsilon model file.
584
+ output_dir (str): Optional path where the parsed data should be saved as a JSON file.
585
+ split_physical_exergy (bool): Flag to split physical exergy into thermal and mechanical components.
586
+
587
+ Returns:
588
+ dict: Parsed data in dictionary format.
589
+
590
+ Raises:
591
+ FileNotFoundError: If the model file is not found at the specified path.
592
+ RuntimeError: For any error during model initialization, simulation, parsing, or writing.
593
+ """
594
+ # Check if Ebsilon is available
595
+ if not is_ebsilon_available():
596
+ raise RuntimeError(
597
+ "Ebsilon functionality is required for running this function. "
598
+ "Please set the EBS environment variable to your Ebsilon installation path."
599
+ )
600
+
601
+ # Check if the model file exists at the specified path
602
+ if not os.path.exists(model_path):
603
+ error_msg = f"Model file not found at: {model_path}"
604
+ logging.error(error_msg)
605
+ raise FileNotFoundError(error_msg)
606
+
607
+ # Initialize the Ebsilon model parser with the model file path
608
+ try:
609
+ parser = EbsilonModelParser(model_path, split_physical_exergy=split_physical_exergy)
610
+ except RuntimeError as e:
611
+ # This will catch the RuntimeError raised in __init__ if Ebsilon is not available
612
+ logging.error(f"Failed to initialize EbsilonModelParser: {e}")
613
+ raise
614
+
615
+ try:
616
+ # Initialize the Ebsilon model within the parser
617
+ parser.initialize_model()
618
+ except FileNotFoundError:
619
+ # allow an invalid/corrupt‐model file to bubble up as FileNotFoundError
620
+ raise
621
+ except Exception as e:
622
+ # other COM/server errors should still be RuntimeErrors
623
+ error_msg = f"File not found: {model_path}"
624
+ logging.error(error_msg)
625
+ raise RuntimeError(error_msg)
626
+
627
+ try:
628
+ # Simulate the Ebsilon model
629
+ parser.simulate_model()
630
+ except Exception as e:
631
+ # Log and raise an error if something goes wrong during simulation
632
+ error_msg = f"An error occurred during model simulation: {e}"
633
+ logging.error(error_msg)
634
+ raise RuntimeError(error_msg)
635
+
636
+ try:
637
+ # Parse data from the simulated model
638
+ parser.parse_model()
639
+ except Exception as e:
640
+ # Log and raise an error if something goes wrong during parsing
641
+ error_msg = f"An error occurred during model parsing: {e}"
642
+ logging.error(error_msg)
643
+ raise RuntimeError(error_msg)
644
+
645
+ # Get the parsed and sorted data
646
+ parsed_data = parser.get_sorted_data()
647
+
648
+ if output_dir is not None:
649
+ try:
650
+ # Write the parsed data to the JSON file
651
+ parser.write_to_json(output_dir)
652
+ logging.info(f"Data successfully written to {output_dir}")
653
+ except Exception as e:
654
+ # Log and raise an error if something goes wrong while writing the output file
655
+ error_msg = f"An error occurred while writing the output file: {e}"
656
+ logging.error(error_msg)
657
+ raise RuntimeError(error_msg)
658
+
659
+ # Return the parsed data as a dictionary (not as a JSON string)
660
+ return parsed_data