geometallurgy 0.4.12__py3-none-any.whl → 0.4.13__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 (48) hide show
  1. elphick/geomet/__init__.py +11 -11
  2. elphick/geomet/base.py +1133 -1133
  3. elphick/geomet/block_model.py +319 -358
  4. elphick/geomet/config/__init__.py +1 -1
  5. elphick/geomet/config/config_read.py +39 -39
  6. elphick/geomet/config/flowsheet_example_partition.yaml +31 -31
  7. elphick/geomet/config/flowsheet_example_simple.yaml +25 -25
  8. elphick/geomet/config/mc_config.yml +35 -35
  9. elphick/geomet/data/downloader.py +39 -39
  10. elphick/geomet/data/register.csv +12 -12
  11. elphick/geomet/datasets/__init__.py +2 -2
  12. elphick/geomet/datasets/datasets.py +47 -47
  13. elphick/geomet/datasets/downloader.py +40 -40
  14. elphick/geomet/datasets/register.csv +12 -12
  15. elphick/geomet/datasets/sample_data.py +196 -196
  16. elphick/geomet/extras.py +35 -35
  17. elphick/geomet/flowsheet/__init__.py +1 -1
  18. elphick/geomet/flowsheet/flowsheet.py +1216 -1216
  19. elphick/geomet/flowsheet/loader.py +99 -99
  20. elphick/geomet/flowsheet/operation.py +256 -256
  21. elphick/geomet/flowsheet/stream.py +39 -39
  22. elphick/geomet/interval_sample.py +641 -641
  23. elphick/geomet/io.py +379 -379
  24. elphick/geomet/plot.py +147 -147
  25. elphick/geomet/sample.py +28 -28
  26. elphick/geomet/utils/amenability.py +49 -49
  27. elphick/geomet/utils/block_model_converter.py +93 -93
  28. elphick/geomet/utils/components.py +136 -136
  29. elphick/geomet/utils/data.py +49 -49
  30. elphick/geomet/utils/estimates.py +108 -108
  31. elphick/geomet/utils/interp.py +193 -193
  32. elphick/geomet/utils/interp2.py +134 -134
  33. elphick/geomet/utils/layout.py +72 -72
  34. elphick/geomet/utils/moisture.py +61 -61
  35. elphick/geomet/utils/output.html +617 -0
  36. elphick/geomet/utils/pandas.py +378 -378
  37. elphick/geomet/utils/parallel.py +29 -29
  38. elphick/geomet/utils/partition.py +63 -63
  39. elphick/geomet/utils/size.py +51 -51
  40. elphick/geomet/utils/timer.py +80 -80
  41. elphick/geomet/utils/viz.py +56 -56
  42. elphick/geomet/validate.py.hide +176 -176
  43. {geometallurgy-0.4.12.dist-info → geometallurgy-0.4.13.dist-info}/LICENSE +21 -21
  44. {geometallurgy-0.4.12.dist-info → geometallurgy-0.4.13.dist-info}/METADATA +7 -5
  45. geometallurgy-0.4.13.dist-info/RECORD +49 -0
  46. {geometallurgy-0.4.12.dist-info → geometallurgy-0.4.13.dist-info}/WHEEL +1 -1
  47. geometallurgy-0.4.12.dist-info/RECORD +0 -48
  48. {geometallurgy-0.4.12.dist-info → geometallurgy-0.4.13.dist-info}/entry_points.txt +0 -0
@@ -1,256 +1,256 @@
1
- from copy import copy
2
- from enum import Enum
3
- from functools import reduce
4
- from typing import Optional, TypeVar, TYPE_CHECKING
5
-
6
- import numpy as np
7
- import pandas as pd
8
-
9
- from elphick.geomet.base import MC
10
- from elphick.geomet.flowsheet.stream import Stream
11
- from elphick.geomet.utils.pandas import MeanIntervalIndex
12
- from elphick.geomet.utils.partition import load_partition_function
13
-
14
- # generic type variable, used for type hinting that play nicely with subclasses
15
- OP = TypeVar('OP', bound='Operation')
16
-
17
- if TYPE_CHECKING:
18
- from elphick.geomet import IntervalSample
19
-
20
-
21
- class NodeType(Enum):
22
- SOURCE = 'input'
23
- SINK = 'output'
24
- BALANCE = 'degree 2+'
25
-
26
-
27
- class Operation:
28
- def __init__(self, name):
29
- self.name = name
30
- self._inputs = []
31
- self._outputs = []
32
- self._is_balanced: Optional[bool] = None
33
- self._unbalanced_records: Optional[pd.DataFrame] = None
34
-
35
- @property
36
- def has_empty_input(self) -> bool:
37
- return None in self.inputs
38
-
39
- @property
40
- def has_empty_output(self) -> bool:
41
- return None in self.outputs
42
-
43
- @property
44
- def inputs(self):
45
- return self._inputs
46
-
47
- @inputs.setter
48
- def inputs(self, value: list[MC]):
49
- self._inputs = value
50
- self.check_balance()
51
-
52
- @property
53
- def outputs(self):
54
- return self._outputs
55
-
56
- @outputs.setter
57
- def outputs(self, value: list[MC]):
58
- self._outputs = value
59
- self.check_balance()
60
-
61
- @property
62
- def node_type(self) -> Optional[NodeType]:
63
- if self.inputs and not self.outputs:
64
- res = NodeType.SINK
65
- elif self.outputs and not self.inputs:
66
- res = NodeType.SOURCE
67
- elif self.inputs and self.outputs:
68
- res = NodeType.BALANCE
69
- else:
70
- res = None
71
- return res
72
-
73
- def get_input_mass(self) -> pd.DataFrame:
74
- inputs = [i for i in self.inputs if i is not None]
75
-
76
- if not inputs:
77
- try:
78
- return self._create_zero_mass()
79
- except:
80
- return pd.DataFrame()
81
- elif len(inputs) == 1:
82
- return inputs[0].mass_data
83
- else:
84
- return reduce(lambda a, b: a.add(b, fill_value=0), [stream.mass_data for stream in inputs])
85
-
86
- def get_output_mass(self) -> pd.DataFrame:
87
- outputs = [o for o in self.outputs if o is not None]
88
-
89
- if not outputs:
90
- try:
91
- return self._create_zero_mass()
92
- except:
93
- return pd.DataFrame()
94
- elif len(outputs) == 1:
95
- return outputs[0].mass_data
96
- else:
97
- return reduce(lambda a, b: a.add(b, fill_value=0), [output.mass_data for output in outputs])
98
-
99
- def check_balance(self):
100
- """Checks if the mass and chemistry of the input and output are balanced"""
101
- if not self.inputs or not self.outputs:
102
- return None
103
-
104
- input_mass, output_mass = self.get_input_mass(), self.get_output_mass()
105
- is_balanced = np.all(np.isclose(input_mass.fillna(0.0), output_mass.fillna(0.0)))
106
- self._unbalanced_records = (input_mass - output_mass).loc[~np.isclose(input_mass, output_mass).any(axis=1)]
107
- self._is_balanced = is_balanced
108
-
109
- @property
110
- def is_balanced(self) -> Optional[bool]:
111
- return self._is_balanced
112
-
113
- @property
114
- def unbalanced_records(self) -> Optional[pd.DataFrame]:
115
- return self._unbalanced_records
116
-
117
- def solve(self) -> Optional[MC]:
118
- """Solves the operation
119
-
120
- Missing data is represented by None in the input and output streams.
121
- Solve will replace None with an object that balances the mass and chemistry of the input and output streams.
122
- Returns
123
- The back-calculated mc object
124
- """
125
-
126
- # Check the number of missing inputs and outputs
127
- missing_count: int = self.inputs.count(None) + self.outputs.count(None)
128
- if missing_count > 1:
129
- raise ValueError("The operation cannot be solved - too many degrees of freedom")
130
- mc = None
131
- if missing_count == 0 and self.is_balanced:
132
- return mc
133
- else:
134
- if None in self.inputs:
135
- ref_object = self.outputs[0]
136
- # Find the index of None in inputs
137
- none_index = self.inputs.index(None)
138
-
139
- # Calculate the None object
140
- new_input_mass: pd.DataFrame = self.get_output_mass() - self.get_input_mass()
141
- # Create a new object from the mass dataframe
142
- mc = type(ref_object).from_mass_dataframe(new_input_mass, mass_wet=ref_object.mass_wet_var,
143
- mass_dry=ref_object.mass_dry_var,
144
- moisture_column_name=ref_object.moisture_column,
145
- component_columns=ref_object.composition_columns,
146
- composition_units=ref_object.composition_units,
147
- moisture_in_scope=ref_object.moisture_in_scope)
148
- # Replace None with the new input
149
- self.inputs[none_index] = mc
150
-
151
- elif None in self.outputs:
152
- ref_object = self.inputs[0]
153
- # Find the index of None in outputs
154
- none_index = self.outputs.index(None)
155
-
156
- # Calculate the None object
157
- if len(self.outputs) == 1 and len(self.inputs) == 1:
158
- # passthrough, no need to calculate. Shallow copy to minimise memory.
159
- mc = copy(self.inputs[0])
160
- mc.name = None
161
- else:
162
- new_output_mass: pd.DataFrame = self.get_input_mass() - self.get_output_mass()
163
- # Create a new object from the mass dataframe
164
- mc = type(ref_object).from_mass_dataframe(new_output_mass, mass_wet=ref_object.mass_wet_var,
165
- mass_dry=ref_object.mass_dry_var,
166
- moisture_column_name=ref_object.moisture_column,
167
- component_columns=ref_object.composition_columns,
168
- composition_units=ref_object.composition_units,
169
- moisture_in_scope=ref_object.moisture_in_scope)
170
-
171
- # Replace None with the new output
172
- self.outputs[none_index] = mc
173
-
174
- # update the balance related attributes
175
- self.check_balance()
176
- return mc
177
-
178
- def _create_zero_mass(self) -> pd.DataFrame:
179
- """Creates a zero mass dataframe with the same columns and index as the mass data"""
180
- # get the firstan object with the mass data
181
- obj = self._get_object()
182
- return pd.DataFrame(data=0, columns=obj.mass_data.columns, index=obj.mass_data.index)
183
-
184
- def _get_object(self, name: Optional[str] = None) -> MC:
185
- """Returns an object from inputs or outputs"""
186
- candidates = [mc for mc in self.outputs + self.inputs if mc is not None]
187
- if len(candidates) == 0:
188
- raise ValueError("No object found")
189
- if name:
190
- for obj in candidates:
191
- if obj is not None and obj.name == name:
192
- return obj
193
- raise ValueError(f"No object found with name {name}")
194
- else:
195
- return candidates[0]
196
-
197
- @classmethod
198
- def from_dict(cls, config: dict) -> 'Operation':
199
- name = config.get('name')
200
-
201
- return cls(name=name)
202
-
203
-
204
- class Input(Operation):
205
- def __init__(self, name):
206
- super().__init__(name)
207
-
208
-
209
- class Output(Operation):
210
- def __init__(self, name):
211
- super().__init__(name)
212
-
213
-
214
- class Passthrough(Operation):
215
- def __init__(self, name):
216
- super().__init__(name)
217
-
218
-
219
- class PartitionOperation(Operation):
220
- """An operation that partitions the input stream into multiple output streams based on a partition function
221
-
222
- The partition input is the mean of the fractions or the geomean if the fractions are in the size dimension
223
- The partition function is typically a partial function so that the partition is defined for all arguments
224
- other than the input mean fraction values in one or two dimensions. The argument names must match the
225
- index names in the IntervalSample.
226
-
227
- """
228
-
229
- def __init__(self, name, partition=None):
230
- super().__init__(name)
231
- self.partition = partition
232
- self.partition_function = None
233
- if self.partition and 'module' in self.partition and 'function' in self.partition:
234
- self.partition_function = load_partition_function(self.partition['module'], self.partition['function'])
235
-
236
- def solve(self) -> [MC, MC]:
237
- if self.partition_function:
238
- self.apply_partition()
239
- # update the balance related attributes
240
- self.check_balance()
241
- return self.outputs
242
-
243
- def apply_partition(self):
244
- if len(self.inputs) != 1:
245
- raise ValueError("PartitionOperation must have exactly one input")
246
- for input_sample in self.inputs:
247
- input_sample: 'IntervalSample'
248
- if input_sample is not None:
249
- output, complement = input_sample.split_by_partition(self.partition_function)
250
- self.outputs = [output, complement]
251
-
252
- @classmethod
253
- def from_dict(cls, config: dict) -> 'PartitionOperation':
254
- name = config.get('name')
255
- partition = config.get('partition')
256
- return cls(name=name, partition=partition)
1
+ from copy import copy
2
+ from enum import Enum
3
+ from functools import reduce
4
+ from typing import Optional, TypeVar, TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from elphick.geomet.base import MC
10
+ from elphick.geomet.flowsheet.stream import Stream
11
+ from elphick.geomet.utils.pandas import MeanIntervalIndex
12
+ from elphick.geomet.utils.partition import load_partition_function
13
+
14
+ # generic type variable, used for type hinting that play nicely with subclasses
15
+ OP = TypeVar('OP', bound='Operation')
16
+
17
+ if TYPE_CHECKING:
18
+ from elphick.geomet import IntervalSample
19
+
20
+
21
+ class NodeType(Enum):
22
+ SOURCE = 'input'
23
+ SINK = 'output'
24
+ BALANCE = 'degree 2+'
25
+
26
+
27
+ class Operation:
28
+ def __init__(self, name):
29
+ self.name = name
30
+ self._inputs = []
31
+ self._outputs = []
32
+ self._is_balanced: Optional[bool] = None
33
+ self._unbalanced_records: Optional[pd.DataFrame] = None
34
+
35
+ @property
36
+ def has_empty_input(self) -> bool:
37
+ return None in self.inputs
38
+
39
+ @property
40
+ def has_empty_output(self) -> bool:
41
+ return None in self.outputs
42
+
43
+ @property
44
+ def inputs(self):
45
+ return self._inputs
46
+
47
+ @inputs.setter
48
+ def inputs(self, value: list[MC]):
49
+ self._inputs = value
50
+ self.check_balance()
51
+
52
+ @property
53
+ def outputs(self):
54
+ return self._outputs
55
+
56
+ @outputs.setter
57
+ def outputs(self, value: list[MC]):
58
+ self._outputs = value
59
+ self.check_balance()
60
+
61
+ @property
62
+ def node_type(self) -> Optional[NodeType]:
63
+ if self.inputs and not self.outputs:
64
+ res = NodeType.SINK
65
+ elif self.outputs and not self.inputs:
66
+ res = NodeType.SOURCE
67
+ elif self.inputs and self.outputs:
68
+ res = NodeType.BALANCE
69
+ else:
70
+ res = None
71
+ return res
72
+
73
+ def get_input_mass(self) -> pd.DataFrame:
74
+ inputs = [i for i in self.inputs if i is not None]
75
+
76
+ if not inputs:
77
+ try:
78
+ return self._create_zero_mass()
79
+ except:
80
+ return pd.DataFrame()
81
+ elif len(inputs) == 1:
82
+ return inputs[0].mass_data
83
+ else:
84
+ return reduce(lambda a, b: a.add(b, fill_value=0), [stream.mass_data for stream in inputs])
85
+
86
+ def get_output_mass(self) -> pd.DataFrame:
87
+ outputs = [o for o in self.outputs if o is not None]
88
+
89
+ if not outputs:
90
+ try:
91
+ return self._create_zero_mass()
92
+ except:
93
+ return pd.DataFrame()
94
+ elif len(outputs) == 1:
95
+ return outputs[0].mass_data
96
+ else:
97
+ return reduce(lambda a, b: a.add(b, fill_value=0), [output.mass_data for output in outputs])
98
+
99
+ def check_balance(self):
100
+ """Checks if the mass and chemistry of the input and output are balanced"""
101
+ if not self.inputs or not self.outputs:
102
+ return None
103
+
104
+ input_mass, output_mass = self.get_input_mass(), self.get_output_mass()
105
+ is_balanced = np.all(np.isclose(input_mass.fillna(0.0), output_mass.fillna(0.0)))
106
+ self._unbalanced_records = (input_mass - output_mass).loc[~np.isclose(input_mass, output_mass).any(axis=1)]
107
+ self._is_balanced = is_balanced
108
+
109
+ @property
110
+ def is_balanced(self) -> Optional[bool]:
111
+ return self._is_balanced
112
+
113
+ @property
114
+ def unbalanced_records(self) -> Optional[pd.DataFrame]:
115
+ return self._unbalanced_records
116
+
117
+ def solve(self) -> Optional[MC]:
118
+ """Solves the operation
119
+
120
+ Missing data is represented by None in the input and output streams.
121
+ Solve will replace None with an object that balances the mass and chemistry of the input and output streams.
122
+ Returns
123
+ The back-calculated mc object
124
+ """
125
+
126
+ # Check the number of missing inputs and outputs
127
+ missing_count: int = self.inputs.count(None) + self.outputs.count(None)
128
+ if missing_count > 1:
129
+ raise ValueError("The operation cannot be solved - too many degrees of freedom")
130
+ mc = None
131
+ if missing_count == 0 and self.is_balanced:
132
+ return mc
133
+ else:
134
+ if None in self.inputs:
135
+ ref_object = self.outputs[0]
136
+ # Find the index of None in inputs
137
+ none_index = self.inputs.index(None)
138
+
139
+ # Calculate the None object
140
+ new_input_mass: pd.DataFrame = self.get_output_mass() - self.get_input_mass()
141
+ # Create a new object from the mass dataframe
142
+ mc = type(ref_object).from_mass_dataframe(new_input_mass, mass_wet=ref_object.mass_wet_var,
143
+ mass_dry=ref_object.mass_dry_var,
144
+ moisture_column_name=ref_object.moisture_column,
145
+ component_columns=ref_object.composition_columns,
146
+ composition_units=ref_object.composition_units,
147
+ moisture_in_scope=ref_object.moisture_in_scope)
148
+ # Replace None with the new input
149
+ self.inputs[none_index] = mc
150
+
151
+ elif None in self.outputs:
152
+ ref_object = self.inputs[0]
153
+ # Find the index of None in outputs
154
+ none_index = self.outputs.index(None)
155
+
156
+ # Calculate the None object
157
+ if len(self.outputs) == 1 and len(self.inputs) == 1:
158
+ # passthrough, no need to calculate. Shallow copy to minimise memory.
159
+ mc = copy(self.inputs[0])
160
+ mc.name = None
161
+ else:
162
+ new_output_mass: pd.DataFrame = self.get_input_mass() - self.get_output_mass()
163
+ # Create a new object from the mass dataframe
164
+ mc = type(ref_object).from_mass_dataframe(new_output_mass, mass_wet=ref_object.mass_wet_var,
165
+ mass_dry=ref_object.mass_dry_var,
166
+ moisture_column_name=ref_object.moisture_column,
167
+ component_columns=ref_object.composition_columns,
168
+ composition_units=ref_object.composition_units,
169
+ moisture_in_scope=ref_object.moisture_in_scope)
170
+
171
+ # Replace None with the new output
172
+ self.outputs[none_index] = mc
173
+
174
+ # update the balance related attributes
175
+ self.check_balance()
176
+ return mc
177
+
178
+ def _create_zero_mass(self) -> pd.DataFrame:
179
+ """Creates a zero mass dataframe with the same columns and index as the mass data"""
180
+ # get the firstan object with the mass data
181
+ obj = self._get_object()
182
+ return pd.DataFrame(data=0, columns=obj.mass_data.columns, index=obj.mass_data.index)
183
+
184
+ def _get_object(self, name: Optional[str] = None) -> MC:
185
+ """Returns an object from inputs or outputs"""
186
+ candidates = [mc for mc in self.outputs + self.inputs if mc is not None]
187
+ if len(candidates) == 0:
188
+ raise ValueError("No object found")
189
+ if name:
190
+ for obj in candidates:
191
+ if obj is not None and obj.name == name:
192
+ return obj
193
+ raise ValueError(f"No object found with name {name}")
194
+ else:
195
+ return candidates[0]
196
+
197
+ @classmethod
198
+ def from_dict(cls, config: dict) -> 'Operation':
199
+ name = config.get('name')
200
+
201
+ return cls(name=name)
202
+
203
+
204
+ class Input(Operation):
205
+ def __init__(self, name):
206
+ super().__init__(name)
207
+
208
+
209
+ class Output(Operation):
210
+ def __init__(self, name):
211
+ super().__init__(name)
212
+
213
+
214
+ class Passthrough(Operation):
215
+ def __init__(self, name):
216
+ super().__init__(name)
217
+
218
+
219
+ class PartitionOperation(Operation):
220
+ """An operation that partitions the input stream into multiple output streams based on a partition function
221
+
222
+ The partition input is the mean of the fractions or the geomean if the fractions are in the size dimension
223
+ The partition function is typically a partial function so that the partition is defined for all arguments
224
+ other than the input mean fraction values in one or two dimensions. The argument names must match the
225
+ index names in the IntervalSample.
226
+
227
+ """
228
+
229
+ def __init__(self, name, partition=None):
230
+ super().__init__(name)
231
+ self.partition = partition
232
+ self.partition_function = None
233
+ if self.partition and 'module' in self.partition and 'function' in self.partition:
234
+ self.partition_function = load_partition_function(self.partition['module'], self.partition['function'])
235
+
236
+ def solve(self) -> [MC, MC]:
237
+ if self.partition_function:
238
+ self.apply_partition()
239
+ # update the balance related attributes
240
+ self.check_balance()
241
+ return self.outputs
242
+
243
+ def apply_partition(self):
244
+ if len(self.inputs) != 1:
245
+ raise ValueError("PartitionOperation must have exactly one input")
246
+ for input_sample in self.inputs:
247
+ input_sample: 'IntervalSample'
248
+ if input_sample is not None:
249
+ output, complement = input_sample.split_by_partition(self.partition_function)
250
+ self.outputs = [output, complement]
251
+
252
+ @classmethod
253
+ def from_dict(cls, config: dict) -> 'PartitionOperation':
254
+ name = config.get('name')
255
+ partition = config.get('partition')
256
+ return cls(name=name, partition=partition)
@@ -1,40 +1,40 @@
1
- import uuid
2
-
3
- from elphick.geomet.base import MassComposition
4
-
5
-
6
- class Stream(MassComposition):
7
- def __init__(self, *args, **kwargs):
8
- super().__init__(*args, **kwargs)
9
- self.nodes = [uuid.uuid4(), uuid.uuid4()]
10
-
11
- def set_parent_node(self, parent: 'Stream') -> 'Stream':
12
- self.nodes = [parent.nodes[1], self.nodes[1]]
13
- return self
14
-
15
- def set_child_node(self, child: 'Stream') -> 'Stream':
16
- self.nodes = [self.nodes[0], child.nodes[0]]
17
- return self
18
-
19
- def set_nodes(self, nodes: list) -> 'Stream':
20
- if len(nodes) != 2:
21
- raise ValueError('Nodes must be a list of length 2')
22
- if nodes[0] == nodes[1]:
23
- raise ValueError('Nodes must be different')
24
- self.nodes = nodes
25
- return self
26
-
27
- # @classmethod
28
- # def from_mass_composition(cls, obj: MassComposition) -> 'Stream':
29
- # filtered_kwargs = filter_kwargs(obj, **obj.__dict__)
30
- # filtered_kwargs['data'] = obj.data
31
- # stream = cls(**filtered_kwargs)
32
- # stream.__class__ = type(obj.__class__.__name__, (obj.__class__, cls), {})
33
- # return stream
34
-
35
- @classmethod
36
- def from_dict(cls, config: dict) -> 'Stream':
37
- name = config.get('name')
38
- node_in = config.get('node_in')
39
- node_out = config.get('node_out')
1
+ import uuid
2
+
3
+ from elphick.geomet.base import MassComposition
4
+
5
+
6
+ class Stream(MassComposition):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self.nodes = [uuid.uuid4(), uuid.uuid4()]
10
+
11
+ def set_parent_node(self, parent: 'Stream') -> 'Stream':
12
+ self.nodes = [parent.nodes[1], self.nodes[1]]
13
+ return self
14
+
15
+ def set_child_node(self, child: 'Stream') -> 'Stream':
16
+ self.nodes = [self.nodes[0], child.nodes[0]]
17
+ return self
18
+
19
+ def set_nodes(self, nodes: list) -> 'Stream':
20
+ if len(nodes) != 2:
21
+ raise ValueError('Nodes must be a list of length 2')
22
+ if nodes[0] == nodes[1]:
23
+ raise ValueError('Nodes must be different')
24
+ self.nodes = nodes
25
+ return self
26
+
27
+ # @classmethod
28
+ # def from_mass_composition(cls, obj: MassComposition) -> 'Stream':
29
+ # filtered_kwargs = filter_kwargs(obj, **obj.__dict__)
30
+ # filtered_kwargs['data'] = obj.data
31
+ # stream = cls(**filtered_kwargs)
32
+ # stream.__class__ = type(obj.__class__.__name__, (obj.__class__, cls), {})
33
+ # return stream
34
+
35
+ @classmethod
36
+ def from_dict(cls, config: dict) -> 'Stream':
37
+ name = config.get('name')
38
+ node_in = config.get('node_in')
39
+ node_out = config.get('node_out')
40
40
  return cls(name=name).set_nodes([node_in, node_out])