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.
- exerpy/__init__.py +12 -0
- exerpy/analyses.py +1711 -0
- exerpy/components/__init__.py +16 -0
- exerpy/components/combustion/__init__.py +0 -0
- exerpy/components/combustion/base.py +248 -0
- exerpy/components/component.py +126 -0
- exerpy/components/heat_exchanger/__init__.py +0 -0
- exerpy/components/heat_exchanger/base.py +449 -0
- exerpy/components/heat_exchanger/condenser.py +323 -0
- exerpy/components/heat_exchanger/simple.py +358 -0
- exerpy/components/heat_exchanger/steam_generator.py +264 -0
- exerpy/components/helpers/__init__.py +0 -0
- exerpy/components/helpers/cycle_closer.py +104 -0
- exerpy/components/nodes/__init__.py +0 -0
- exerpy/components/nodes/deaerator.py +318 -0
- exerpy/components/nodes/drum.py +164 -0
- exerpy/components/nodes/flash_tank.py +89 -0
- exerpy/components/nodes/mixer.py +332 -0
- exerpy/components/piping/__init__.py +0 -0
- exerpy/components/piping/valve.py +394 -0
- exerpy/components/power_machines/__init__.py +0 -0
- exerpy/components/power_machines/generator.py +168 -0
- exerpy/components/power_machines/motor.py +173 -0
- exerpy/components/turbomachinery/__init__.py +0 -0
- exerpy/components/turbomachinery/compressor.py +318 -0
- exerpy/components/turbomachinery/pump.py +310 -0
- exerpy/components/turbomachinery/turbine.py +351 -0
- exerpy/data/Ahrendts.json +90 -0
- exerpy/functions.py +637 -0
- exerpy/parser/__init__.py +0 -0
- exerpy/parser/from_aspen/__init__.py +0 -0
- exerpy/parser/from_aspen/aspen_config.py +61 -0
- exerpy/parser/from_aspen/aspen_parser.py +721 -0
- exerpy/parser/from_ebsilon/__init__.py +38 -0
- exerpy/parser/from_ebsilon/check_ebs_path.py +74 -0
- exerpy/parser/from_ebsilon/ebsilon_config.py +1055 -0
- exerpy/parser/from_ebsilon/ebsilon_functions.py +181 -0
- exerpy/parser/from_ebsilon/ebsilon_parser.py +660 -0
- exerpy/parser/from_ebsilon/utils.py +79 -0
- exerpy/parser/from_tespy/tespy_config.py +23 -0
- exerpy-0.0.1.dist-info/METADATA +158 -0
- exerpy-0.0.1.dist-info/RECORD +44 -0
- exerpy-0.0.1.dist-info/WHEEL +4 -0
- exerpy-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from exerpy.functions import convert_to_SI
|
|
6
|
+
from exerpy.functions import fluid_property_data
|
|
7
|
+
|
|
8
|
+
from .aspen_config import connector_mappings
|
|
9
|
+
from .aspen_config import grouped_components
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AspenModelParser:
|
|
13
|
+
"""
|
|
14
|
+
A class to parse Aspen Plus models, simulate them, extract data, and write to JSON.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, model_path, split_physical_exergy=True):
|
|
17
|
+
"""
|
|
18
|
+
Initializes the parser with the given model path.
|
|
19
|
+
|
|
20
|
+
Parameters:
|
|
21
|
+
model_path (str): Path to the Aspen Plus model file.
|
|
22
|
+
split_physical_exergy (bool): Flag to split physical exergy into thermal and mechanical components.
|
|
23
|
+
"""
|
|
24
|
+
self.model_path = model_path
|
|
25
|
+
self.split_physical_exergy = split_physical_exergy
|
|
26
|
+
self.aspen = None # Aspen Plus application instance
|
|
27
|
+
self.components_data = {} # Dictionary to store component data
|
|
28
|
+
self.connections_data = {} # Dictionary to store connection data
|
|
29
|
+
|
|
30
|
+
# Dictionary to map component types to specific connector assignment functions
|
|
31
|
+
self.connector_assignment_functions = {
|
|
32
|
+
'Mixer': self.assign_mixer_connectors,
|
|
33
|
+
'RStoic': self.assign_combustion_chamber_connectors,
|
|
34
|
+
'FSplit': self.assign_splitter_connectors,
|
|
35
|
+
# Add other specific component functions here
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def initialize_model(self):
|
|
40
|
+
"""
|
|
41
|
+
Initializes the Aspen Plus application and opens the specified model.
|
|
42
|
+
"""
|
|
43
|
+
from win32com.client import Dispatch
|
|
44
|
+
try:
|
|
45
|
+
# Start Aspen Plus application via COM Dispatch
|
|
46
|
+
self.aspen = Dispatch('Apwn.Document')
|
|
47
|
+
# Load the Aspen model file
|
|
48
|
+
self.aspen.InitFromArchive2(self.model_path)
|
|
49
|
+
logging.info(f"Model opened successfully: {self.model_path}")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logging.error(f"Failed to initialize the model: {e}")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
def parse_model(self):
|
|
55
|
+
"""
|
|
56
|
+
Parses the components and connections from the Aspen model.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
# Parse Tamb and pamb
|
|
60
|
+
self.parse_ambient_conditions()
|
|
61
|
+
|
|
62
|
+
# Parse streams (connections)
|
|
63
|
+
self.parse_streams()
|
|
64
|
+
|
|
65
|
+
# Parse blocks (components)
|
|
66
|
+
self.parse_blocks()
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logging.error(f"Error while parsing the model: {e}")
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_streams(self):
|
|
74
|
+
"""
|
|
75
|
+
Parses the streams (connections) in the Aspen model.
|
|
76
|
+
"""
|
|
77
|
+
# Get the stream nodes and their names
|
|
78
|
+
stream_nodes = self.aspen.Tree.FindNode(r'\Data\Streams').Elements
|
|
79
|
+
stream_names = [stream_node.Name for stream_node in stream_nodes]
|
|
80
|
+
|
|
81
|
+
# ALL ASPEN CONNECTIONS
|
|
82
|
+
# Initialize connection data with the common fields
|
|
83
|
+
for stream_name in stream_names:
|
|
84
|
+
stream_node = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}')
|
|
85
|
+
connection_data = {
|
|
86
|
+
'name': stream_name,
|
|
87
|
+
'kind': None,
|
|
88
|
+
'source_component': None,
|
|
89
|
+
'source_connector': None,
|
|
90
|
+
'target_component': None,
|
|
91
|
+
'target_connector': None,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Find the source and target components
|
|
95
|
+
source_port_node = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Ports\SOURCE')
|
|
96
|
+
if source_port_node is not None and source_port_node.Elements.Count > 0:
|
|
97
|
+
connection_data["source_component"] = source_port_node.Elements(0).Name
|
|
98
|
+
|
|
99
|
+
destination_port_node = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Ports\DEST')
|
|
100
|
+
if destination_port_node is not None and destination_port_node.Elements.Count > 0:
|
|
101
|
+
connection_data["target_component"] = destination_port_node.Elements(0).Name
|
|
102
|
+
|
|
103
|
+
# HEAT AND POWER STREAMS
|
|
104
|
+
if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Input\WORK') is not None:
|
|
105
|
+
connection_data['kind'] = 'power'
|
|
106
|
+
connection_data['energy_flow'] = convert_to_SI(
|
|
107
|
+
'power',
|
|
108
|
+
abs(self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\POWER_OUT').Value),
|
|
109
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\POWER_OUT').UnitString
|
|
110
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\POWER_OUT') is not None else None
|
|
111
|
+
elif self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Input\HEAT') is not None:
|
|
112
|
+
connection_data['kind'] = 'heat'
|
|
113
|
+
connection_data['energy_flow'] = convert_to_SI(
|
|
114
|
+
'power',
|
|
115
|
+
abs(self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\QCALC').Value),
|
|
116
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\QCALC').UnitString
|
|
117
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\QCALC') is not None else None
|
|
118
|
+
|
|
119
|
+
# MATERIAL STREAMS
|
|
120
|
+
else:
|
|
121
|
+
# Assume it's a material stream and retrieve additional properties
|
|
122
|
+
connection_data.update({
|
|
123
|
+
'kind': 'material',
|
|
124
|
+
'T': (
|
|
125
|
+
convert_to_SI(
|
|
126
|
+
'T',
|
|
127
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TEMP_OUT\MIXED').Value,
|
|
128
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TEMP_OUT\MIXED').UnitString
|
|
129
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TEMP_OUT\MIXED') is not None else None
|
|
130
|
+
),
|
|
131
|
+
'T_unit': fluid_property_data['T']['SI_unit'],
|
|
132
|
+
'p': (
|
|
133
|
+
convert_to_SI(
|
|
134
|
+
'p',
|
|
135
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\PRES_OUT\MIXED').Value,
|
|
136
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\PRES_OUT\MIXED').UnitString
|
|
137
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\PRES_OUT\MIXED') is not None else None
|
|
138
|
+
),
|
|
139
|
+
'p_unit': fluid_property_data['p']['SI_unit'],
|
|
140
|
+
'h': (
|
|
141
|
+
convert_to_SI(
|
|
142
|
+
'h',
|
|
143
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_MASS\MIXED').Value,
|
|
144
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_MASS\MIXED').UnitString
|
|
145
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_MASS\MIXED') is not None else None
|
|
146
|
+
),
|
|
147
|
+
'h_unit': fluid_property_data['h']['SI_unit'],
|
|
148
|
+
's': (
|
|
149
|
+
convert_to_SI(
|
|
150
|
+
's',
|
|
151
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\SMX_MASS\MIXED').Value,
|
|
152
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\SMX_MASS\MIXED').UnitString
|
|
153
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\SMX_MASS\MIXED') is not None else None
|
|
154
|
+
),
|
|
155
|
+
's_unit': fluid_property_data['s']['SI_unit'],
|
|
156
|
+
'm': (
|
|
157
|
+
convert_to_SI(
|
|
158
|
+
'm',
|
|
159
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MASSFLMX\MIXED').Value,
|
|
160
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MASSFLMX\MIXED').UnitString
|
|
161
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MASSFLMX\MIXED') is not None else None
|
|
162
|
+
),
|
|
163
|
+
'm_unit': fluid_property_data['m']['SI_unit'],
|
|
164
|
+
'energy_flow': (
|
|
165
|
+
convert_to_SI(
|
|
166
|
+
'power',
|
|
167
|
+
abs(self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_FLOW\MIXED').Value),
|
|
168
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_FLOW\MIXED').UnitString
|
|
169
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\HMX_FLOW\MIXED') is not None else None
|
|
170
|
+
),
|
|
171
|
+
'energy_flow_unit': fluid_property_data['power']['SI_unit'],
|
|
172
|
+
'e_PH': (
|
|
173
|
+
convert_to_SI(
|
|
174
|
+
'e',
|
|
175
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\STRM_UPP\EXERGYMS\MIXED\TOTAL').Value,
|
|
176
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\STRM_UPP\EXERGYMS\MIXED\TOTAL').UnitString
|
|
177
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\STRM_UPP\EXERGYMS\MIXED\TOTAL') is not None else (
|
|
178
|
+
logging.warning(f"e_PH node not found for stream {stream_name}"),
|
|
179
|
+
None
|
|
180
|
+
)[1]
|
|
181
|
+
),
|
|
182
|
+
'e_PH_unit': fluid_property_data['e']['SI_unit'],
|
|
183
|
+
'n': (
|
|
184
|
+
convert_to_SI(
|
|
185
|
+
'n',
|
|
186
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TOT_FLOW').Value,
|
|
187
|
+
self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TOT_FLOW').UnitString
|
|
188
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\TOT_FLOW') is not None else None
|
|
189
|
+
),
|
|
190
|
+
'n_unit': fluid_property_data['n']['SI_unit'],
|
|
191
|
+
'mass_composition': {},
|
|
192
|
+
'molar_composition': {},
|
|
193
|
+
})
|
|
194
|
+
# Retrieve the fluid names for the stream
|
|
195
|
+
mole_frac_node = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MOLEFRAC\MIXED')
|
|
196
|
+
if mole_frac_node is not None:
|
|
197
|
+
fluid_names = [fluid.Name for fluid in mole_frac_node.Elements]
|
|
198
|
+
|
|
199
|
+
# Retrieve the molar composition for each fluid
|
|
200
|
+
for fluid_name in fluid_names:
|
|
201
|
+
mole_frac = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MOLEFRAC\MIXED\{fluid_name}').Value
|
|
202
|
+
if mole_frac not in [0, None]: # Skip fluids with 0 or None as the fraction
|
|
203
|
+
connection_data["molar_composition"][fluid_name] = mole_frac
|
|
204
|
+
|
|
205
|
+
mass_frac_node = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MASSFRAC\MIXED')
|
|
206
|
+
if mass_frac_node is not None:
|
|
207
|
+
# Retrieve the mass composition for each fluid
|
|
208
|
+
for fluid_name in [fluid.Name for fluid in mass_frac_node.Elements]:
|
|
209
|
+
mass_frac = self.aspen.Tree.FindNode(fr'\Data\Streams\{stream_name}\Output\MASSFRAC\MIXED\{fluid_name}').Value
|
|
210
|
+
if mass_frac not in [0, None]: # Skip fluids with 0 or None as the fraction
|
|
211
|
+
connection_data["mass_composition"][fluid_name] = mass_frac
|
|
212
|
+
|
|
213
|
+
# Store connection data
|
|
214
|
+
self.connections_data[stream_name] = connection_data
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_blocks(self):
|
|
218
|
+
"""
|
|
219
|
+
Parses the blocks (components) in the Aspen model and ensures that all components, including motors created from pumps, are properly grouped.
|
|
220
|
+
"""
|
|
221
|
+
block_nodes = self.aspen.Tree.FindNode(r'\Data\Blocks').Elements
|
|
222
|
+
block_names = [block_node.Name for block_node in block_nodes]
|
|
223
|
+
|
|
224
|
+
# Process each block
|
|
225
|
+
for block_name in block_names:
|
|
226
|
+
model_type_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Input\MODEL_TYPE')
|
|
227
|
+
model_type = model_type_node.Value if model_type_node is not None else None
|
|
228
|
+
|
|
229
|
+
component_type_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}')
|
|
230
|
+
if component_type_node is None:
|
|
231
|
+
continue
|
|
232
|
+
component_type = component_type_node.AttributeValue(6)
|
|
233
|
+
if component_type == "Mixer":
|
|
234
|
+
mixer_value = component_type_node.Value
|
|
235
|
+
if mixer_value in ["TRIANGLE", "HEAT"]:
|
|
236
|
+
logging.info(f"Ignoring Mixer {block_name} with value {mixer_value}.")
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
component_data = {
|
|
240
|
+
'name': block_name,
|
|
241
|
+
'type': component_type,
|
|
242
|
+
'eta_s': (
|
|
243
|
+
self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\EFF_ISEN').Value
|
|
244
|
+
if self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\EFF_ISEN') is not None else None
|
|
245
|
+
),
|
|
246
|
+
'eta_mech': (
|
|
247
|
+
self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\EFF_MECH').Value
|
|
248
|
+
if self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\EFF_MECH') is not None else None
|
|
249
|
+
),
|
|
250
|
+
'Q': (
|
|
251
|
+
convert_to_SI(
|
|
252
|
+
'heat',
|
|
253
|
+
self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\QNET').Value,
|
|
254
|
+
self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\QNET').UnitString,
|
|
255
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\QNET') is not None else None
|
|
256
|
+
),
|
|
257
|
+
'Q_unit': fluid_property_data['heat']['SI_unit'],
|
|
258
|
+
'P': (
|
|
259
|
+
convert_to_SI(
|
|
260
|
+
'power',
|
|
261
|
+
abs(self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\BRAKE_POWER').Value),
|
|
262
|
+
self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\BRAKE_POWER').UnitString,
|
|
263
|
+
) if self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\BRAKE_POWER') is not None else None
|
|
264
|
+
),
|
|
265
|
+
'P_unit': fluid_property_data['power']['SI_unit'],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Override component type based on model_type
|
|
269
|
+
if model_type is not None:
|
|
270
|
+
if model_type == "COMPRESSOR":
|
|
271
|
+
component_data['type'] = "Compressor"
|
|
272
|
+
elif model_type == "TURBINE":
|
|
273
|
+
component_data['type'] = "Turbine"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Handle Generators & Motors (if not in a Pump) as multiplier blocks
|
|
277
|
+
if component_type == 'Mult':
|
|
278
|
+
mult_value_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}')
|
|
279
|
+
mult_value = mult_value_node.Value if mult_value_node is not None else None
|
|
280
|
+
if mult_value == 'WORK':
|
|
281
|
+
factor_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Input\FACTOR')
|
|
282
|
+
factor = factor_node.Value if factor_node is not None else None
|
|
283
|
+
if factor is not None:
|
|
284
|
+
if factor < 1:
|
|
285
|
+
component_data.update({
|
|
286
|
+
'eta_el': factor,
|
|
287
|
+
'type': 'Generator'
|
|
288
|
+
})
|
|
289
|
+
elif factor > 1:
|
|
290
|
+
elec_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\WS(OUT)').Elements(0)
|
|
291
|
+
elec_power_name = elec_power_node.Name
|
|
292
|
+
if elec_power_name in self.connections_data:
|
|
293
|
+
elec_power = abs(self.connections_data[elec_power_name]['energy_flow'])
|
|
294
|
+
else:
|
|
295
|
+
logging.warning(f"No WS(IN) ports found for block {block_name}")
|
|
296
|
+
elec_power = None
|
|
297
|
+
brake_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\WS(IN)').Elements(0)
|
|
298
|
+
brake_power_name = brake_power_node.Name
|
|
299
|
+
if brake_power_name in self.connections_data:
|
|
300
|
+
brake_power = abs(self.connections_data[brake_power_name]['energy_flow'])
|
|
301
|
+
else:
|
|
302
|
+
logging.warning(f"No WS(IN) ports found for block {block_name}")
|
|
303
|
+
brake_power = None
|
|
304
|
+
component_data.update({
|
|
305
|
+
'eta_el': 1/factor,
|
|
306
|
+
'multiplier factor' : factor,
|
|
307
|
+
'type': 'Motor',
|
|
308
|
+
'P_el': elec_power,
|
|
309
|
+
'P_el_unit': fluid_property_data['power']['SI_unit'],
|
|
310
|
+
'P_mech': brake_power,
|
|
311
|
+
'P_mech_unit': fluid_property_data['power']['SI_unit'],
|
|
312
|
+
})
|
|
313
|
+
else: # factor == 1
|
|
314
|
+
choice = input(f"Multiplier Block '{block_name}' has factor = 1. Enter 'G' if it is a Generator or 'M' for Motor: ").strip().upper()
|
|
315
|
+
if choice == 'M':
|
|
316
|
+
elec_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\WS(OUT)').Elements(0)
|
|
317
|
+
elec_power_name = elec_power_node.Name
|
|
318
|
+
if elec_power_name in self.connections_data:
|
|
319
|
+
elec_power = abs(self.connections_data[elec_power_name]['energy_flow'])
|
|
320
|
+
else:
|
|
321
|
+
logging.warning(f"No WS(IN) ports found for block {block_name}")
|
|
322
|
+
elec_power = None
|
|
323
|
+
brake_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\WS(IN)').Elements(0)
|
|
324
|
+
brake_power_name = brake_power_node.Name
|
|
325
|
+
if brake_power_name in self.connections_data:
|
|
326
|
+
brake_power = abs(self.connections_data[brake_power_name]['energy_flow'])
|
|
327
|
+
else:
|
|
328
|
+
logging.warning(f"No WS(IN) ports found for block {block_name}")
|
|
329
|
+
brake_power = None
|
|
330
|
+
component_data.update({
|
|
331
|
+
'eta_el': factor,
|
|
332
|
+
'type': 'Motor',
|
|
333
|
+
'P_el': elec_power,
|
|
334
|
+
'P_el_unit': fluid_property_data['power']['SI_unit'],
|
|
335
|
+
'P_mech': brake_power,
|
|
336
|
+
'P_mech_unit': fluid_property_data['power']['SI_unit'],
|
|
337
|
+
})
|
|
338
|
+
else:
|
|
339
|
+
component_data.update({
|
|
340
|
+
'eta_el': factor,
|
|
341
|
+
'type': 'Generator'
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
# Create a connection for the heat flows of the SimpleHeatExchanger blocks
|
|
345
|
+
if component_type == 'Heater':
|
|
346
|
+
heat_connection_name = f"{block_name}_HEAT"
|
|
347
|
+
heat_connection_data = {
|
|
348
|
+
'name': heat_connection_name,
|
|
349
|
+
'kind': 'heat',
|
|
350
|
+
'source_component': block_name,
|
|
351
|
+
'source_connector': 1, # 00 is reserved for the fluid streams
|
|
352
|
+
'target_component': None, # Heat assumed to leave the system (not relevant for exergy analysis)
|
|
353
|
+
'target_connector': None, # Heat assumed to leave the system (not relevant for exergy analysis)
|
|
354
|
+
'energy_flow': abs(component_data['Q']), # the user defines in the balances if the heat flow is positive or negative
|
|
355
|
+
'energy_flow_unit': fluid_property_data['heat']['SI_unit'],
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Store the heat connection
|
|
359
|
+
self.connections_data[heat_connection_name] = heat_connection_data
|
|
360
|
+
|
|
361
|
+
# Group the component
|
|
362
|
+
self.group_component(component_data, block_name)
|
|
363
|
+
|
|
364
|
+
# Handle Pumps and their associated Motors
|
|
365
|
+
if component_type == 'Pump':
|
|
366
|
+
motor_name = f"{block_name}-MOTOR"
|
|
367
|
+
elec_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\ELEC_POWER')
|
|
368
|
+
elec_power = abs(convert_to_SI('power', elec_power_node.Value, elec_power_node.UnitString,)) if elec_power_node is not None else None
|
|
369
|
+
brake_power_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\BRAKE_POWER')
|
|
370
|
+
brake_power = abs(convert_to_SI('power', brake_power_node.Value, brake_power_node.UnitString,)) if brake_power_node is not None else None
|
|
371
|
+
eff_driv_node = self.aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Output\EFF_DRIV')
|
|
372
|
+
eff_driv = eff_driv_node.Value if eff_driv_node is not None else None
|
|
373
|
+
|
|
374
|
+
motor_data = {
|
|
375
|
+
'name': motor_name,
|
|
376
|
+
'type': 'Motor',
|
|
377
|
+
'P_el': elec_power,
|
|
378
|
+
'P_el_unit': fluid_property_data['power']['SI_unit'],
|
|
379
|
+
'P_mech': brake_power,
|
|
380
|
+
'P_mech_unit': fluid_property_data['power']['SI_unit'],
|
|
381
|
+
'eta_el': (
|
|
382
|
+
eff_driv
|
|
383
|
+
if eff_driv is not None else None
|
|
384
|
+
),
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# Group the motor
|
|
388
|
+
self.group_component(motor_data, motor_name)
|
|
389
|
+
|
|
390
|
+
# Create a new connection for the motor
|
|
391
|
+
if elec_power is not None:
|
|
392
|
+
electr_connection_name = f"{block_name}_ELEC"
|
|
393
|
+
electr_connection_data = {
|
|
394
|
+
'name': electr_connection_name,
|
|
395
|
+
'kind': 'power',
|
|
396
|
+
'source_component': None, # Electrical power usually leaves the system
|
|
397
|
+
'source_connector': None, # Electrical power usually leaves the system
|
|
398
|
+
'target_component': motor_name,
|
|
399
|
+
'target_connector': 0,
|
|
400
|
+
'energy_flow': motor_data['P_el'],
|
|
401
|
+
'energy_flow_unit': fluid_property_data['power']['SI_unit'],
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
mech_connection_name = f"{block_name}_MECH"
|
|
405
|
+
mech_connection_data = {
|
|
406
|
+
'name': mech_connection_name,
|
|
407
|
+
'kind': 'power',
|
|
408
|
+
'source_component': motor_name,
|
|
409
|
+
'source_connector': 0,
|
|
410
|
+
'target_component': block_name,
|
|
411
|
+
'target_connector': 1,
|
|
412
|
+
'energy_flow': motor_data['P_mech'],
|
|
413
|
+
'energy_flow_unit': fluid_property_data['power']['SI_unit'],
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Store the motor connection
|
|
417
|
+
self.connections_data[electr_connection_name] = electr_connection_data
|
|
418
|
+
self.connections_data[mech_connection_name] = mech_connection_data
|
|
419
|
+
|
|
420
|
+
# Assign connectors
|
|
421
|
+
self.assign_connectors(component_data, block_name)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def assign_connectors(self, component_data, block_name):
|
|
425
|
+
"""
|
|
426
|
+
Assigns connectors to streams for each component based on its type.
|
|
427
|
+
"""
|
|
428
|
+
component_type = component_data['type']
|
|
429
|
+
|
|
430
|
+
# Check if there is a specific assignment function for this component type
|
|
431
|
+
if component_type in self.connector_assignment_functions:
|
|
432
|
+
# Call the specific function for the component type
|
|
433
|
+
self.connector_assignment_functions[component_type](block_name, self.aspen, self.connections_data)
|
|
434
|
+
else:
|
|
435
|
+
# Fall back to the generic connector assignment logic
|
|
436
|
+
self.assign_generic_connectors(block_name, component_type, self.aspen, self.connections_data, connector_mappings)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def assign_mixer_connectors(self, block_name, aspen, connections_data):
|
|
440
|
+
"""
|
|
441
|
+
Assign connectors for a Mixer by examining connected streams and their source/target components.
|
|
442
|
+
"""
|
|
443
|
+
ports_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports')
|
|
444
|
+
if ports_node is None:
|
|
445
|
+
logging.warning(f"No Ports node found for Mixer block: {block_name}")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
inlet_streams = []
|
|
449
|
+
outlet_streams = []
|
|
450
|
+
|
|
451
|
+
for port in ports_node.Elements:
|
|
452
|
+
port_label = port.Name
|
|
453
|
+
port_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\{port_label}')
|
|
454
|
+
if port_node is not None and port_node.Elements.Count > 0:
|
|
455
|
+
for element in port_node.Elements:
|
|
456
|
+
stream_name = element.Name
|
|
457
|
+
if stream_name in connections_data:
|
|
458
|
+
stream_data = connections_data[stream_name]
|
|
459
|
+
if stream_data.get('target_component') == block_name:
|
|
460
|
+
inlet_streams.append((port_label, stream_name))
|
|
461
|
+
elif stream_data.get('source_component') == block_name:
|
|
462
|
+
outlet_streams.append((port_label, stream_name))
|
|
463
|
+
else:
|
|
464
|
+
logging.warning(f"Stream {stream_name} connected to {block_name} but source/target components do not match.")
|
|
465
|
+
|
|
466
|
+
# Assign connectors to inlet streams
|
|
467
|
+
for idx, (port_label, stream_name) in enumerate(inlet_streams):
|
|
468
|
+
connections_data[stream_name]['target_connector'] = idx
|
|
469
|
+
logging.debug(f"Assigned connector {idx} to inlet stream: {stream_name}")
|
|
470
|
+
|
|
471
|
+
# Assign connector to outlet stream
|
|
472
|
+
if outlet_streams:
|
|
473
|
+
for idx, (port_label, stream_name) in enumerate(outlet_streams):
|
|
474
|
+
connections_data[stream_name]['source_connector'] = 0 # Assuming single outlet for mixer
|
|
475
|
+
logging.debug(f"Assigned connector 0 to outlet stream: {stream_name}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def assign_splitter_connectors(self, block_name, aspen, connections_data):
|
|
479
|
+
"""
|
|
480
|
+
Assign connectors for a Splitter (FSplit) by examining connected streams and their source/target components.
|
|
481
|
+
The inlet stream is assigned 'target_connector' = 0.
|
|
482
|
+
The outlet streams are assigned 'source_connector' numbers starting from 0.
|
|
483
|
+
"""
|
|
484
|
+
ports_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports')
|
|
485
|
+
if ports_node is None:
|
|
486
|
+
logging.warning(f"No Ports node found for Splitter block: {block_name}")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
inlet_streams = []
|
|
490
|
+
outlet_streams = []
|
|
491
|
+
|
|
492
|
+
# Iterate over all ports connected to the splitter
|
|
493
|
+
for port in ports_node.Elements:
|
|
494
|
+
port_label = port.Name
|
|
495
|
+
port_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\{port_label}')
|
|
496
|
+
if port_node is not None and port_node.Elements.Count > 0:
|
|
497
|
+
for element in port_node.Elements:
|
|
498
|
+
stream_name = element.Name
|
|
499
|
+
if stream_name in connections_data:
|
|
500
|
+
stream_data = connections_data[stream_name]
|
|
501
|
+
# Determine if the stream is an inlet or outlet based on source and target components
|
|
502
|
+
if stream_data.get('target_component') == block_name:
|
|
503
|
+
inlet_streams.append((port_label, stream_name))
|
|
504
|
+
elif stream_data.get('source_component') == block_name:
|
|
505
|
+
outlet_streams.append((port_label, stream_name))
|
|
506
|
+
else:
|
|
507
|
+
logging.warning(f"Stream {stream_name} connected to {block_name} but source/target components do not match.")
|
|
508
|
+
|
|
509
|
+
# Assign connector to inlet stream(s)
|
|
510
|
+
for idx, (port_label, stream_name) in enumerate(inlet_streams):
|
|
511
|
+
connections_data[stream_name]['target_connector'] = 0 # Assuming single inlet for splitter
|
|
512
|
+
logging.debug(f"Assigned connector 0 to inlet stream: {stream_name}")
|
|
513
|
+
|
|
514
|
+
# Assign connectors to outlet streams
|
|
515
|
+
for idx, (port_label, stream_name) in enumerate(outlet_streams):
|
|
516
|
+
connections_data[stream_name]['source_connector'] = idx
|
|
517
|
+
logging.debug(f"Assigned connector {idx} to outlet stream: {stream_name}")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def assign_combustion_chamber_connectors(self, block_name, aspen, connections_data):
|
|
521
|
+
"""
|
|
522
|
+
Assign connectors for a combustion chamber (RStoic), based on stream types (air, fuel, etc.).
|
|
523
|
+
"""
|
|
524
|
+
ports_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports')
|
|
525
|
+
if ports_node is None:
|
|
526
|
+
logging.warning(f"No Ports node found for combustion chamber block: {block_name}")
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# Iterate over all ports and assign connectors based on port labels
|
|
530
|
+
for port in ports_node.Elements:
|
|
531
|
+
port_label = port.Name
|
|
532
|
+
|
|
533
|
+
# Handle inlet ports
|
|
534
|
+
if '(IN)' in port_label:
|
|
535
|
+
port_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\{port_label}')
|
|
536
|
+
if port_node is not None and port_node.Elements.Count > 0:
|
|
537
|
+
for element in port_node.Elements:
|
|
538
|
+
stream_name = element.Name
|
|
539
|
+
if stream_name in connections_data:
|
|
540
|
+
molar_composition = connections_data[stream_name].get('molar_composition', {})
|
|
541
|
+
if molar_composition.get('O2', 0) > 0.15:
|
|
542
|
+
connections_data[stream_name]['target_connector'] = 0 # Air inlet
|
|
543
|
+
logging.debug(f"Assigned connector 0 to air inlet stream: {stream_name}")
|
|
544
|
+
elif molar_composition.get('CH4', 0) > 0.15:
|
|
545
|
+
connections_data[stream_name]['target_connector'] = 1 # Fuel inlet
|
|
546
|
+
logging.debug(f"Assigned connector 1 to fuel inlet stream: {stream_name}")
|
|
547
|
+
else:
|
|
548
|
+
logging.warning(f"Stream {stream_name} in {block_name} has ambiguous composition.")
|
|
549
|
+
|
|
550
|
+
# Handle outlet ports
|
|
551
|
+
elif '(OUT)' in port_label:
|
|
552
|
+
port_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\{port_label}')
|
|
553
|
+
if port_node is not None and port_node.Elements.Count > 0:
|
|
554
|
+
for element in port_node.Elements:
|
|
555
|
+
stream_name = element.Name
|
|
556
|
+
if stream_name in connections_data:
|
|
557
|
+
connections_data[stream_name]['source_connector'] = 0 # Outlet stream
|
|
558
|
+
logging.info(f"Assigned connector 0 to outlet stream: {stream_name}")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def assign_generic_connectors(self, block_name, component_type, aspen, connections_data, connector_mappings):
|
|
562
|
+
"""
|
|
563
|
+
Generic function for components with predefined connector mappings.
|
|
564
|
+
"""
|
|
565
|
+
if component_type in connector_mappings:
|
|
566
|
+
mapping = connector_mappings[component_type]
|
|
567
|
+
|
|
568
|
+
# Access the ports of the component to find the connected streams
|
|
569
|
+
for port_label, connector_num in mapping.items():
|
|
570
|
+
port_node = aspen.Tree.FindNode(fr'\Data\Blocks\{block_name}\Ports\{port_label}')
|
|
571
|
+
if port_node is not None and port_node.Elements.Count > 0:
|
|
572
|
+
for element in port_node.Elements:
|
|
573
|
+
stream_name = element.Name
|
|
574
|
+
# Assign the connector number to the appropriate stream in the connection data
|
|
575
|
+
if stream_name in connections_data:
|
|
576
|
+
if 'source_component' in connections_data[stream_name] and \
|
|
577
|
+
connections_data[stream_name]['source_component'] == block_name:
|
|
578
|
+
connections_data[stream_name]['source_connector'] = connector_num
|
|
579
|
+
logging.debug(f"Assigned connector {connector_num} to source stream: {stream_name}")
|
|
580
|
+
elif 'target_component' in connections_data[stream_name] and \
|
|
581
|
+
connections_data[stream_name]['target_component'] == block_name:
|
|
582
|
+
connections_data[stream_name]['target_connector'] = connector_num
|
|
583
|
+
logging.debug(f"Assigned connector {connector_num} to target stream: {stream_name}")
|
|
584
|
+
else:
|
|
585
|
+
logging.warning(f"No connector mapping defined for component type {component_type}.")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def group_component(self, component_data, component_name):
|
|
589
|
+
"""
|
|
590
|
+
Group the component based on its type into the correct group within components_data.
|
|
591
|
+
|
|
592
|
+
Parameters:
|
|
593
|
+
- component_data: The dictionary of component attributes.
|
|
594
|
+
- component_name: The name of the component.
|
|
595
|
+
"""
|
|
596
|
+
# Determine the group for the component based on its type
|
|
597
|
+
group = None
|
|
598
|
+
for group_name, type_list in grouped_components.items():
|
|
599
|
+
if component_data['type'] in type_list:
|
|
600
|
+
group = group_name
|
|
601
|
+
break
|
|
602
|
+
|
|
603
|
+
# If the component doesn't belong to any predefined group, use its type name
|
|
604
|
+
if not group:
|
|
605
|
+
group = component_data['type']
|
|
606
|
+
|
|
607
|
+
# Initialize the group in the components_data dictionary if not already present
|
|
608
|
+
if group not in self.components_data:
|
|
609
|
+
self.components_data[group] = {}
|
|
610
|
+
|
|
611
|
+
# Store the component data in the appropriate group
|
|
612
|
+
self.components_data[group][component_name] = component_data
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def parse_ambient_conditions(self):
|
|
616
|
+
"""
|
|
617
|
+
Parses the ambient conditions from the Aspen model and stores them as class attributes.
|
|
618
|
+
Raises an error if Tamb or pamb are not found or are set to None.
|
|
619
|
+
"""
|
|
620
|
+
try:
|
|
621
|
+
# Parse ambient temperature (Tamb)
|
|
622
|
+
temp_node = self.aspen.Tree.FindNode(r"\Data\Setup\Sim-Options\Input\REF_TEMP")
|
|
623
|
+
self.Tamb = convert_to_SI(
|
|
624
|
+
'T',
|
|
625
|
+
temp_node.Value,
|
|
626
|
+
temp_node.UnitString
|
|
627
|
+
) if temp_node is not None else None
|
|
628
|
+
|
|
629
|
+
if self.Tamb is None:
|
|
630
|
+
raise ValueError("Ambient temperature (Tamb) not found in the Aspen model. Please set it in Setup > Calculation Options.")
|
|
631
|
+
|
|
632
|
+
# Parse ambient pressure (pamb)
|
|
633
|
+
pres_node = self.aspen.Tree.FindNode(r"\Data\Setup\Sim-Options\Input\REF_PRES")
|
|
634
|
+
self.pamb = convert_to_SI(
|
|
635
|
+
'p',
|
|
636
|
+
pres_node.Value,
|
|
637
|
+
pres_node.UnitString
|
|
638
|
+
) if pres_node is not None else None
|
|
639
|
+
|
|
640
|
+
if self.pamb is None:
|
|
641
|
+
raise ValueError("Ambient pressure (pamb) not found in the Aspen model. Please set it in Setup > Calculation Options.")
|
|
642
|
+
|
|
643
|
+
logging.info(f"Parsed ambient conditions: Tamb = {self.Tamb} K, pamb = {self.pamb} Pa")
|
|
644
|
+
|
|
645
|
+
except Exception as e:
|
|
646
|
+
logging.error(f"Error parsing ambient conditions: {e}")
|
|
647
|
+
raise
|
|
648
|
+
|
|
649
|
+
def get_sorted_data(self):
|
|
650
|
+
"""
|
|
651
|
+
Sorts the component and connection data alphabetically by name.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
dict: A dictionary containing sorted 'components', 'connections', and ambient conditions data.
|
|
655
|
+
"""
|
|
656
|
+
sorted_components = {comp_name: self.components_data[comp_name] for comp_name in sorted(self.components_data)}
|
|
657
|
+
sorted_connections = {conn_name: self.connections_data[conn_name] for conn_name in sorted(self.connections_data)}
|
|
658
|
+
ambient_conditions = {
|
|
659
|
+
'Tamb': self.Tamb,
|
|
660
|
+
'Tamb_unit': fluid_property_data['T']['SI_unit'],
|
|
661
|
+
'pamb': self.pamb,
|
|
662
|
+
'pamb_unit': fluid_property_data['p']['SI_unit']
|
|
663
|
+
}
|
|
664
|
+
return {'components': sorted_components, 'connections': sorted_connections, 'ambient_conditions': ambient_conditions}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def write_to_json(self, output_path):
|
|
668
|
+
"""
|
|
669
|
+
Writes the parsed and sorted data to a JSON file.
|
|
670
|
+
|
|
671
|
+
Parameters:
|
|
672
|
+
output_path (str): Path where the JSON file will be saved.
|
|
673
|
+
"""
|
|
674
|
+
data = self.get_sorted_data()
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
with open(output_path, 'w') as json_file:
|
|
678
|
+
json.dump(data, json_file, indent=4)
|
|
679
|
+
logging.info(f"Data successfully written to {output_path}")
|
|
680
|
+
except Exception as e:
|
|
681
|
+
logging.error(f"Failed to write data to JSON: {e}")
|
|
682
|
+
raise
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def run_aspen(model_path, output_dir=None, split_physical_exergy=True):
|
|
686
|
+
"""
|
|
687
|
+
Main function to process the Aspen model and return parsed data.
|
|
688
|
+
Optionally writes the parsed data to a JSON file.
|
|
689
|
+
|
|
690
|
+
Parameters:
|
|
691
|
+
model_path (str): Path to the Aspen model file.
|
|
692
|
+
output_dir (str): Optional path where the parsed data should be saved as a JSON file.
|
|
693
|
+
split_physical_exergy (bool): Flag to split physical exergy into thermal and mechanical components.
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
dict: Parsed data in dictionary format.
|
|
697
|
+
"""
|
|
698
|
+
if not os.path.exists(model_path):
|
|
699
|
+
error_msg = f"Model file not found at: {model_path}"
|
|
700
|
+
logging.error(error_msg)
|
|
701
|
+
raise FileNotFoundError(error_msg)
|
|
702
|
+
|
|
703
|
+
parser = AspenModelParser(model_path, split_physical_exergy=split_physical_exergy)
|
|
704
|
+
|
|
705
|
+
try:
|
|
706
|
+
parser.initialize_model()
|
|
707
|
+
parser.parse_model()
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logging.error(f"An error occurred: {e}")
|
|
710
|
+
raise RuntimeError(f"An error occurred: {e}")
|
|
711
|
+
|
|
712
|
+
parsed_data = parser.get_sorted_data()
|
|
713
|
+
|
|
714
|
+
if output_dir is not None:
|
|
715
|
+
try:
|
|
716
|
+
parser.write_to_json(output_dir)
|
|
717
|
+
except Exception as e:
|
|
718
|
+
logging.error(f"Failed to write output file: {e}")
|
|
719
|
+
raise RuntimeError(f"Failed to write output file: {e}")
|
|
720
|
+
|
|
721
|
+
return parsed_data
|