geometallurgy 0.4.4__tar.gz → 0.4.12__tar.gz
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.
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/PKG-INFO +1 -1
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/__init__.py +0 -1
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/base.py +333 -13
- geometallurgy-0.4.12/elphick/geomet/config/flowsheet_example_partition.yaml +31 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/mc_config.yml +1 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/flowsheet.py +220 -44
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/operation.py +63 -10
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/stream.py +14 -12
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/interval_sample.py +110 -32
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/block_model_converter.py +5 -4
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/components.py +1 -1
- geometallurgy-0.4.12/elphick/geomet/utils/estimates.py +108 -0
- geometallurgy-0.4.12/elphick/geomet/utils/interp.py +193 -0
- geometallurgy-0.4.12/elphick/geomet/utils/interp2.py +134 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/pandas.py +25 -30
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/partition.py +18 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/size.py +2 -2
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/timer.py +1 -1
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/pyproject.toml +8 -2
- geometallurgy-0.4.4/elphick/geomet/utils/interp.py +0 -107
- geometallurgy-0.4.4/elphick/geomet/utils/sampling.py +0 -5
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/LICENSE +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/README.md +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/block_model.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/__init__.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/config_read.py +0 -0
- /geometallurgy-0.4.4/elphick/geomet/config/flowsheet_example.yaml → /geometallurgy-0.4.12/elphick/geomet/config/flowsheet_example_simple.yaml +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/data/downloader.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/data/register.csv +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/__init__.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/datasets.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/downloader.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/register.csv +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/sample_data.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/extras.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/__init__.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/loader.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/io.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/plot.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/profile.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/sample.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/__init__.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/amenability.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/data.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/layout.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/moisture.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/parallel.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/viz.py +0 -0
- {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/validate.py.hide +0 -0
|
@@ -2,23 +2,23 @@ import copy
|
|
|
2
2
|
import inspect
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
|
+
import uuid
|
|
5
6
|
from abc import ABC
|
|
6
7
|
from pathlib import Path
|
|
7
|
-
from typing import Optional, Union, Literal, TypeVar, TYPE_CHECKING
|
|
8
|
+
from typing import Optional, Union, Literal, TypeVar, TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
import numpy as np
|
|
10
11
|
import pandas as pd
|
|
12
|
+
import plotly.express as px
|
|
13
|
+
import plotly.graph_objects as go
|
|
11
14
|
|
|
12
15
|
from elphick.geomet.config import read_yaml
|
|
13
16
|
from elphick.geomet.utils.components import get_components, is_compositional
|
|
14
17
|
from elphick.geomet.utils.moisture import solve_mass_moisture
|
|
15
18
|
from elphick.geomet.utils.pandas import mass_to_composition, composition_to_mass, composition_factors
|
|
16
|
-
from elphick.geomet.utils.sampling import random_int
|
|
17
19
|
from elphick.geomet.utils.timer import log_timer
|
|
18
20
|
from .config.config_read import get_column_config
|
|
19
21
|
from .plot import parallel_plot, comparison_plot
|
|
20
|
-
import plotly.express as px
|
|
21
|
-
import plotly.graph_objects as go
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
24
|
from elphick.geomet.flowsheet.stream import Stream
|
|
@@ -122,7 +122,8 @@ class MassComposition(ABC):
|
|
|
122
122
|
mass_wet=self.mass_wet_var, mass_dry=self.mass_dry_var,
|
|
123
123
|
moisture_column_name=self.moisture_column,
|
|
124
124
|
component_columns=composition.columns,
|
|
125
|
-
composition_units=self.composition_units
|
|
125
|
+
composition_units=self.composition_units,
|
|
126
|
+
return_moisture=False)
|
|
126
127
|
self._logger.debug(f"Data has been set.")
|
|
127
128
|
|
|
128
129
|
else:
|
|
@@ -138,6 +139,22 @@ class MassComposition(ABC):
|
|
|
138
139
|
# Recalculate the aggregate whenever the data changes
|
|
139
140
|
self.aggregate = self.weight_average()
|
|
140
141
|
|
|
142
|
+
def get_mass_data(self, include_moisture: bool = True) -> pd.DataFrame:
|
|
143
|
+
"""Get the mass data
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
include_moisture: If True (and moisture is in scope), include the moisture mass column
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
if include_moisture and self.moisture_in_scope:
|
|
152
|
+
moisture_mass = self._mass_data[self.mass_wet_var] - self._mass_data[self.mass_dry_var]
|
|
153
|
+
mass_data: pd.DataFrame = self._mass_data.copy()
|
|
154
|
+
mass_data.insert(loc=2, column=self.moisture_column, value=moisture_mass)
|
|
155
|
+
return mass_data
|
|
156
|
+
return self._mass_data
|
|
157
|
+
|
|
141
158
|
@property
|
|
142
159
|
def aggregate(self) -> pd.DataFrame:
|
|
143
160
|
if self._aggregate is None and self._mass_data is not None:
|
|
@@ -213,6 +230,172 @@ class MassComposition(ABC):
|
|
|
213
230
|
(self.mass_columns + [self.moisture_column] + self.composition_columns + self.supplementary_columns) if
|
|
214
231
|
col is not None]
|
|
215
232
|
|
|
233
|
+
def balance_composition(self, epsilon: float = 1.0e-6) -> MC:
|
|
234
|
+
"""Balance the composition data
|
|
235
|
+
|
|
236
|
+
For records where the component mass exceeds the dry mass, the component masses are reduced proportionally
|
|
237
|
+
to equal the dry mass. Records where the component mass is less than the dry mass are left unchanged.
|
|
238
|
+
|
|
239
|
+
epsilon: A small float to avoid component sums marginally over 100.0
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The object with balanced composition.
|
|
243
|
+
"""
|
|
244
|
+
if self._mass_data is not None:
|
|
245
|
+
# calculate the ratio of the sum of the components to the dry mass
|
|
246
|
+
ratio = self._mass_data[self.composition_columns].sum(axis=1) / self._mass_data[self.mass_dry_var]
|
|
247
|
+
if ratio.max() <= 1.0:
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
before_reduction = self._mass_data[self.composition_columns].copy()
|
|
251
|
+
# add a small value to the ratio to avoid component sums marginally over 100.0
|
|
252
|
+
ratio[ratio > 1.0] = ratio[ratio > 1.0] + epsilon
|
|
253
|
+
# to avoid reducing compliant records, clip the ratio at the lower side to 1.0
|
|
254
|
+
ratio = ratio.clip(lower=1.0)
|
|
255
|
+
# apply the ratio to the components
|
|
256
|
+
self._mass_data[self.composition_columns] = self._mass_data[self.composition_columns].div(ratio, axis=0)
|
|
257
|
+
|
|
258
|
+
# manage the reporting
|
|
259
|
+
affected_indexes = set(
|
|
260
|
+
self._mass_data.index[np.any(before_reduction != self._mass_data[self.composition_columns], axis=1)])
|
|
261
|
+
# log the action, including the first 50 indexes affected
|
|
262
|
+
affected_indexes_list = sorted(affected_indexes)[:50]
|
|
263
|
+
|
|
264
|
+
self.aggregate = self.weight_average()
|
|
265
|
+
self.status = OutOfRangeStatus(self, self.status.ranges)
|
|
266
|
+
|
|
267
|
+
self._logger.info(f"Object {self.name} has been balanced. "
|
|
268
|
+
f"{len(affected_indexes)} records where composition has been reduced "
|
|
269
|
+
f"to conform to dry mass. "
|
|
270
|
+
f"Affected indexes (first 50): {affected_indexes_list}")
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def clip_recovery(self, other: MC, recovery_bounds: tuple[float, float] = (0.01, 0.99),
|
|
274
|
+
allow_moisture_coercion: bool = True) -> MC:
|
|
275
|
+
"""Clip the recovery to the specified bounds and recalculate the estimate.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
other: The other MassComposition object, from which the recovery of self is calculated.
|
|
279
|
+
recovery_bounds: The bounds for the recovery between 0.0 and 1.0
|
|
280
|
+
allow_moisture_coercion: if True, allow the wet mass to be modified to maintain the moisture (in the
|
|
281
|
+
case that dry mass is clipped to manage recovery)
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The MassComposition object with the recovery clipped to the bounds.
|
|
285
|
+
"""
|
|
286
|
+
recovery: pd.DataFrame = (self.get_mass_data(include_moisture=False) /
|
|
287
|
+
other.get_mass_data(include_moisture=False))
|
|
288
|
+
|
|
289
|
+
# Limit the recovery to the bounds
|
|
290
|
+
before_clip = recovery.copy()
|
|
291
|
+
recovery = recovery.clip(lower=recovery_bounds[0], upper=recovery_bounds[1]).fillna(0.0)
|
|
292
|
+
|
|
293
|
+
# Check if any records were affected
|
|
294
|
+
affected_indexes = set(recovery.index[np.any(before_clip != recovery, axis=1)])
|
|
295
|
+
if affected_indexes:
|
|
296
|
+
# Recalculate the estimate from the bound recovery
|
|
297
|
+
new_mass: pd.DataFrame = recovery * other.get_mass_data(include_moisture=False)[recovery.columns]
|
|
298
|
+
|
|
299
|
+
if self.moisture_in_scope and allow_moisture_coercion:
|
|
300
|
+
# Calculate the moisture from the new mass
|
|
301
|
+
new_mass[self.mass_wet_var] = solve_mass_moisture(mass_dry=new_mass[self.mass_dry_var],
|
|
302
|
+
moisture=self.data[self.moisture_column])
|
|
303
|
+
|
|
304
|
+
# Log the top 50 records affected by the recovery coercion
|
|
305
|
+
affected_indexes_list = sorted(affected_indexes)[:50]
|
|
306
|
+
self._logger.info(f"Recovery coercion affected {len(affected_indexes)} records. "
|
|
307
|
+
f"Affected indexes (first 50): {affected_indexes_list}")
|
|
308
|
+
|
|
309
|
+
# Update the mass data of self
|
|
310
|
+
self.update_mass_data(new_mass)
|
|
311
|
+
else:
|
|
312
|
+
self._logger.info("Recovery coercion did not affect any records.")
|
|
313
|
+
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def set_moisture(self, moisture: Union[pd.Series, float, int], mass_to_adjust: Literal['wet', 'dry'] = 'wet') -> MC:
|
|
317
|
+
"""Set the moisture to the specified value
|
|
318
|
+
|
|
319
|
+
A convenience method for an mc object that modifies the concrete mass to deliver the specified moisture.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
moisture: The moisture value to set. Can be a constant or series.
|
|
323
|
+
mass_to_adjust: The mass to adjust, either 'wet' or 'dry'.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
if not self.moisture_in_scope:
|
|
330
|
+
raise AssertionError("This method is not applicable unless moisture_in_scope property is True.")
|
|
331
|
+
|
|
332
|
+
if isinstance(moisture, float) or isinstance(moisture, int):
|
|
333
|
+
# create a series with the same index as the mass data
|
|
334
|
+
moisture = pd.Series(float(moisture), index=self._mass_data.index)
|
|
335
|
+
elif not isinstance(moisture, pd.Series):
|
|
336
|
+
raise TypeError(f"moisture must be a float or a pd.Series, not {type(moisture)}")
|
|
337
|
+
|
|
338
|
+
if mass_to_adjust == 'wet':
|
|
339
|
+
self._mass_data[self.mass_wet_var] = solve_mass_moisture(mass_dry=self._mass_data[self.mass_dry_var],
|
|
340
|
+
moisture=moisture)
|
|
341
|
+
elif mass_to_adjust == 'dry':
|
|
342
|
+
self._mass_data[self.mass_dry_var] = solve_mass_moisture(mass_wet=self._mass_data[self.mass_wet_var],
|
|
343
|
+
moisture=moisture)
|
|
344
|
+
else:
|
|
345
|
+
raise ValueError(f"mass_to_adjust must be 'wet' or 'dry', not {mass_to_adjust}")
|
|
346
|
+
|
|
347
|
+
self.aggregate = self.weight_average()
|
|
348
|
+
self.status = OutOfRangeStatus(self, self.status.ranges)
|
|
349
|
+
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
def clip_composition(self, ranges: Optional[dict[str, list[float]]] = None, epsilon: float = 1.0e-05) -> MC:
|
|
353
|
+
"""Clip the components
|
|
354
|
+
|
|
355
|
+
Clip to the components to within the range provided or the default range for each component.
|
|
356
|
+
This method does not clip moisture - see set_moisture and solve_moisture for that.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
ranges: An optional dict defining a list of [lo, hi] floats for each component. If not provided,
|
|
360
|
+
the default range from the config file will be used.
|
|
361
|
+
epsilon: A small float to ensure the clipped values lie marginally inside the specified range.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The object with clipped composition.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
# load the default ranges from the config file
|
|
368
|
+
component_ranges: dict = self._get_component_ranges(ranges)
|
|
369
|
+
|
|
370
|
+
# define a small value to ensure the clipped values lie marginally inside the specified range.
|
|
371
|
+
# clip the components
|
|
372
|
+
affected_indexes = set()
|
|
373
|
+
for component, component_range in component_ranges.items():
|
|
374
|
+
before_clip = self._mass_data[component].copy()
|
|
375
|
+
# add a small value to the range to ensure the clipped values lie marginally inside the specified range.
|
|
376
|
+
component_range = [component_range[0] + epsilon, component_range[1] - epsilon]
|
|
377
|
+
# define the component mass that aligns with the lower and upper bounds
|
|
378
|
+
component_mass_limits = self._mass_data[self.mass_dry_var].values[:, np.newaxis] * np.array(
|
|
379
|
+
component_range) / self.composition_factor
|
|
380
|
+
# apply the clip to the mass data
|
|
381
|
+
self._mass_data[component] = self._mass_data[component].clip(lower=component_mass_limits[:, 0],
|
|
382
|
+
upper=component_mass_limits[:, 1])
|
|
383
|
+
affected_indexes.update(self._mass_data.index[before_clip != self._mass_data[component]])
|
|
384
|
+
|
|
385
|
+
# log the action, including the first 50 indexes affected
|
|
386
|
+
affected_indexes_list = sorted(affected_indexes)[:50]
|
|
387
|
+
|
|
388
|
+
self.aggregate = self.weight_average()
|
|
389
|
+
self.status = OutOfRangeStatus(self, self.status.ranges)
|
|
390
|
+
|
|
391
|
+
if len(affected_indexes) > 0:
|
|
392
|
+
self._logger.info(f"Object {self.name} has been clipped. "
|
|
393
|
+
f"{len(affected_indexes)} records where composition has been clipped to the "
|
|
394
|
+
f"range: {component_ranges}. "
|
|
395
|
+
f"Affected indexes (first 50): {affected_indexes_list}")
|
|
396
|
+
|
|
397
|
+
return self
|
|
398
|
+
|
|
216
399
|
def plot_parallel(self, color: Optional[str] = None,
|
|
217
400
|
vars_include: Optional[list[str]] = None,
|
|
218
401
|
vars_exclude: Optional[list[str]] = None,
|
|
@@ -352,7 +535,8 @@ class MassComposition(ABC):
|
|
|
352
535
|
|
|
353
536
|
# Create a DataFrame from the weighted averages
|
|
354
537
|
weighted_averages_df = pd.concat([mass_sum, composition], axis=1)
|
|
355
|
-
|
|
538
|
+
|
|
539
|
+
else: # group by a variable
|
|
356
540
|
group_var: pd.Series = self._supplementary_data[group_by]
|
|
357
541
|
weighted_averages_df = self._mass_data.groupby(group_var).apply(
|
|
358
542
|
lambda x: pd.DataFrame(
|
|
@@ -447,7 +631,7 @@ class MassComposition(ABC):
|
|
|
447
631
|
non_mass_cols: list[str] = [col for col in value.columns if
|
|
448
632
|
col not in [self.mass_wet_var, self.mass_dry_var, self.moisture_var, 'h2o',
|
|
449
633
|
'H2O', 'H2O']]
|
|
450
|
-
component_cols: list[str] = get_components(value[non_mass_cols], strict=False)
|
|
634
|
+
component_cols: list[str] = get_components(value[non_mass_cols].columns, strict=False)
|
|
451
635
|
else:
|
|
452
636
|
component_cols: list[str] = self.component_vars
|
|
453
637
|
composition = value[component_cols]
|
|
@@ -479,6 +663,7 @@ class MassComposition(ABC):
|
|
|
479
663
|
self._supplementary_data.index = self._mass_data.index
|
|
480
664
|
self._supplementary_data = self._supplementary_data.loc[value.index]
|
|
481
665
|
self.aggregate = self.weight_average()
|
|
666
|
+
self.status = OutOfRangeStatus(self, self.status.ranges)
|
|
482
667
|
|
|
483
668
|
def filter_by_index(self, index: pd.Index):
|
|
484
669
|
"""Update the data by index"""
|
|
@@ -529,8 +714,8 @@ class MassComposition(ABC):
|
|
|
529
714
|
comp: 'Stream'
|
|
530
715
|
|
|
531
716
|
# create the relationships
|
|
532
|
-
ref.nodes = [self.nodes[1],
|
|
533
|
-
comp.nodes = [self.nodes[1],
|
|
717
|
+
ref.nodes = [self.nodes[1], uuid.uuid4()]
|
|
718
|
+
comp.nodes = [self.nodes[1], uuid.uuid4()]
|
|
534
719
|
|
|
535
720
|
return ref, comp
|
|
536
721
|
|
|
@@ -549,6 +734,10 @@ class MassComposition(ABC):
|
|
|
549
734
|
|
|
550
735
|
res: MC = self.create_congruent_object(name=name, include_mc_data=True,
|
|
551
736
|
include_supp_data=include_supplementary_data)
|
|
737
|
+
|
|
738
|
+
if set(self._mass_data.columns) != set(other._mass_data.columns):
|
|
739
|
+
raise ValueError(f"Mass data columns do not match: {set(self._mass_data.columns)} != "
|
|
740
|
+
f"{set(other._mass_data.columns)}")
|
|
552
741
|
res.update_mass_data(self._mass_data + other._mass_data)
|
|
553
742
|
|
|
554
743
|
# Ensure self and other are Stream objects
|
|
@@ -558,7 +747,7 @@ class MassComposition(ABC):
|
|
|
558
747
|
|
|
559
748
|
# create the relationships
|
|
560
749
|
other.nodes = [other.nodes[0], self.nodes[1]]
|
|
561
|
-
res.nodes = [self.nodes[1],
|
|
750
|
+
res.nodes = [self.nodes[1], uuid.uuid4()]
|
|
562
751
|
|
|
563
752
|
return res
|
|
564
753
|
|
|
@@ -576,6 +765,11 @@ class MassComposition(ABC):
|
|
|
576
765
|
"""
|
|
577
766
|
res = self.create_congruent_object(name=name, include_mc_data=True,
|
|
578
767
|
include_supp_data=include_supplementary_data)
|
|
768
|
+
|
|
769
|
+
if set(self._mass_data.columns) != set(other._mass_data.columns):
|
|
770
|
+
raise ValueError(f"Mass data columns do not match: {set(self._mass_data.columns)} != "
|
|
771
|
+
f"{set(other._mass_data.columns)}")
|
|
772
|
+
|
|
579
773
|
res.update_mass_data(self._mass_data - other._mass_data)
|
|
580
774
|
|
|
581
775
|
# Ensure self and other are Stream objects
|
|
@@ -585,7 +779,8 @@ class MassComposition(ABC):
|
|
|
585
779
|
other: 'Stream'
|
|
586
780
|
|
|
587
781
|
# create the relationships
|
|
588
|
-
|
|
782
|
+
other.nodes = [self.nodes[1], other.nodes[1]]
|
|
783
|
+
res.nodes = [self.nodes[1], uuid.uuid4()]
|
|
589
784
|
|
|
590
785
|
return res
|
|
591
786
|
|
|
@@ -696,7 +891,7 @@ class MassComposition(ABC):
|
|
|
696
891
|
moisture_column_name: Optional[str] = None,
|
|
697
892
|
component_columns: Optional[list[str]] = None,
|
|
698
893
|
composition_units: Literal['%', 'ppm', 'ppb'] = '%',
|
|
699
|
-
**kwargs):
|
|
894
|
+
**kwargs) -> MC:
|
|
700
895
|
"""
|
|
701
896
|
Class method to create a MassComposition object from a mass dataframe.
|
|
702
897
|
|
|
@@ -737,6 +932,41 @@ class MassComposition(ABC):
|
|
|
737
932
|
|
|
738
933
|
return res
|
|
739
934
|
|
|
935
|
+
def compare(self, other: 'MassComposition', comparisons: Union[str, list[str]] = 'recovery',
|
|
936
|
+
explicit_names: bool = True) -> pd.DataFrame:
|
|
937
|
+
|
|
938
|
+
comparisons = [comparisons] if isinstance(comparisons, str) else comparisons
|
|
939
|
+
valid_comparisons: set = {'recovery', 'difference', 'divide', 'all'}
|
|
940
|
+
|
|
941
|
+
cols = [col for col in self.data.data_vars if col not in self.data.mc.mc_vars_attrs]
|
|
942
|
+
|
|
943
|
+
chunks: list[pd.DataFrame] = []
|
|
944
|
+
if 'recovery' in comparisons or comparisons == ['all']:
|
|
945
|
+
df: pd.DataFrame = self._mass_data[self.component_vars] / other._mass_data[self.component_vars]
|
|
946
|
+
if explicit_names:
|
|
947
|
+
df.columns = [f"{self.name}_{col}_{self.config['comparisons']['recovery']}_{other.name}" for col in
|
|
948
|
+
df.columns]
|
|
949
|
+
chunks.append(df)
|
|
950
|
+
if 'difference' in comparisons or comparisons == ['all']:
|
|
951
|
+
df: pd.DataFrame = self.data[cols] - other.data[cols]
|
|
952
|
+
if explicit_names:
|
|
953
|
+
df.columns = [f"{self.name}_{col}_{self.config['comparisons']['difference']}_{other.name}" for col in
|
|
954
|
+
df.columns]
|
|
955
|
+
chunks.append(df)
|
|
956
|
+
if 'divide' in comparisons or comparisons == ['all']:
|
|
957
|
+
df: pd.DataFrame = self.data[cols] / other.data[cols]
|
|
958
|
+
if explicit_names:
|
|
959
|
+
df.columns = [f"{self.name}_{col}_{self.config['comparisons']['divide']}_{other.name}" for col in
|
|
960
|
+
df.columns]
|
|
961
|
+
chunks.append(df)
|
|
962
|
+
|
|
963
|
+
if not chunks:
|
|
964
|
+
raise ValueError(f"The comparison argument is not valid: {valid_comparisons}")
|
|
965
|
+
|
|
966
|
+
res: pd.DataFrame = pd.concat(chunks, axis=1)
|
|
967
|
+
|
|
968
|
+
return res
|
|
969
|
+
|
|
740
970
|
def reset_index(self, index_name: str) -> MC:
|
|
741
971
|
res = self.create_congruent_object(name=f"{self.name} (reset_index)", include_mc_data=True,
|
|
742
972
|
include_supp_data=True)
|
|
@@ -749,6 +979,20 @@ class MassComposition(ABC):
|
|
|
749
979
|
|
|
750
980
|
return res
|
|
751
981
|
|
|
982
|
+
def _get_component_ranges(self, ranges: dict[str, list]) -> dict[str, list]:
|
|
983
|
+
|
|
984
|
+
d_ranges: dict = get_column_config(config_dict=self.config, var_map=self.variable_map,
|
|
985
|
+
config_key='range')
|
|
986
|
+
# filter to include only components
|
|
987
|
+
d_ranges = {k: v for k, v in d_ranges.items() if k in self.composition_columns}
|
|
988
|
+
|
|
989
|
+
# modify the default dict based on any user passed constraints
|
|
990
|
+
if ranges:
|
|
991
|
+
for k, v in ranges.items():
|
|
992
|
+
d_ranges[k] = v
|
|
993
|
+
|
|
994
|
+
return d_ranges
|
|
995
|
+
|
|
752
996
|
|
|
753
997
|
class OutOfRangeStatus:
|
|
754
998
|
"""A class to check and report out-of-range records in an MC object."""
|
|
@@ -767,7 +1011,7 @@ class OutOfRangeStatus:
|
|
|
767
1011
|
self.oor: pd.DataFrame = self._check_range()
|
|
768
1012
|
self.num_oor: int = len(self.oor)
|
|
769
1013
|
self.failing_components: Optional[list[str]] = list(
|
|
770
|
-
self.oor.dropna(axis=1).columns) if self.num_oor > 0 else None
|
|
1014
|
+
self.oor.dropna(axis=1, how='all').columns) if self.num_oor > 0 else None
|
|
771
1015
|
|
|
772
1016
|
def get_ranges(self, ranges: dict[str, list]) -> dict[str, list]:
|
|
773
1017
|
|
|
@@ -811,3 +1055,79 @@ class OutOfRangeStatus:
|
|
|
811
1055
|
if isinstance(other, OutOfRangeStatus):
|
|
812
1056
|
return self.oor.equals(other.oor)
|
|
813
1057
|
return False
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
class SampleStatus:
|
|
1061
|
+
"""A class to check and report sample status in an MC object.
|
|
1062
|
+
|
|
1063
|
+
A MassComposition object (Sample, Stream, etc) can be unhealthy if:
|
|
1064
|
+
1. the total mass of components is greater than the dry mass
|
|
1065
|
+
2. any of the masses are negative
|
|
1066
|
+
3. the component values are out of range
|
|
1067
|
+
"""
|
|
1068
|
+
|
|
1069
|
+
def __init__(self, mc: 'MC', component_limits: dict):
|
|
1070
|
+
"""Initialize with an MC object."""
|
|
1071
|
+
self._logger = logging.getLogger(__name__)
|
|
1072
|
+
self.mc: 'MC' = mc
|
|
1073
|
+
self.sample_status: Optional[dict] = None
|
|
1074
|
+
self.num_samples: Optional[int] = None
|
|
1075
|
+
self.failing_samples: Optional[list[str]] = None
|
|
1076
|
+
|
|
1077
|
+
if mc.mass_data is not None:
|
|
1078
|
+
self.sample_status = self.get_sample_status(component_limits)
|
|
1079
|
+
self.num_samples = len(self.sample_status)
|
|
1080
|
+
self.failing_samples = [sample for sample, status in self.sample_status.items() if not status['ok']]
|
|
1081
|
+
|
|
1082
|
+
def get_sample_status(self, component_limits: dict) -> dict:
|
|
1083
|
+
"""Check if all records are within the constraints."""
|
|
1084
|
+
sample_status = {}
|
|
1085
|
+
if self.mc._mass_data is not None:
|
|
1086
|
+
df: pd.DataFrame = self.mc.data[self.mc.composition_columns]
|
|
1087
|
+
mass_dry = self.mc._mass_data[self.mc.mass_dry_var]
|
|
1088
|
+
|
|
1089
|
+
# Check for component limits
|
|
1090
|
+
for component, limits in component_limits.items():
|
|
1091
|
+
oor = df[(df[component] < limits[0]) | (df[component] > limits[1])]
|
|
1092
|
+
for sample in oor.index:
|
|
1093
|
+
if sample not in sample_status:
|
|
1094
|
+
sample_status[sample] = {'ok': True, 'causes': []}
|
|
1095
|
+
sample_status[sample]['ok'] = False
|
|
1096
|
+
sample_status[sample]['causes'].append(f"{component} out of range")
|
|
1097
|
+
|
|
1098
|
+
# Check if total mass of components is greater than dry mass
|
|
1099
|
+
total_component_mass = df.sum(axis=1)
|
|
1100
|
+
for sample in total_component_mass.index:
|
|
1101
|
+
if sample not in sample_status:
|
|
1102
|
+
sample_status[sample] = {'ok': True, 'causes': []}
|
|
1103
|
+
if total_component_mass[sample] > mass_dry[sample]:
|
|
1104
|
+
sample_status[sample]['ok'] = False
|
|
1105
|
+
sample_status[sample]['causes'].append("Total mass of components greater than dry mass")
|
|
1106
|
+
|
|
1107
|
+
# Check for negative masses
|
|
1108
|
+
negative_masses = df[df < 0].dropna(how='all')
|
|
1109
|
+
for sample in negative_masses.index:
|
|
1110
|
+
if sample not in sample_status:
|
|
1111
|
+
sample_status[sample] = {'ok': True, 'causes': []}
|
|
1112
|
+
sample_status[sample]['ok'] = False
|
|
1113
|
+
sample_status[sample]['causes'].append("Negative mass detected")
|
|
1114
|
+
return sample_status
|
|
1115
|
+
|
|
1116
|
+
@property
|
|
1117
|
+
def ok(self) -> bool:
|
|
1118
|
+
"""Return True if all records are within range, False otherwise."""
|
|
1119
|
+
if self.num_samples > 0:
|
|
1120
|
+
self._logger.warning(f'{self.num_samples} unhealthy samples exist.')
|
|
1121
|
+
return True if self.num_samples == 0 else False
|
|
1122
|
+
|
|
1123
|
+
def __str__(self) -> str:
|
|
1124
|
+
"""Return a string representation of the status."""
|
|
1125
|
+
res: str = f'status.ok: {self.ok}\n'
|
|
1126
|
+
res += f'num_samples: {self.num_samples}'
|
|
1127
|
+
return res
|
|
1128
|
+
|
|
1129
|
+
def __eq__(self, other: object) -> bool:
|
|
1130
|
+
"""Return True if other Status has the same out-of-range records."""
|
|
1131
|
+
if isinstance(other, SampleStatus):
|
|
1132
|
+
return self.sample_status == other.sample_status
|
|
1133
|
+
return False
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
FLOWSHEET:
|
|
2
|
+
flowsheet:
|
|
3
|
+
name: Flowsheet
|
|
4
|
+
streams: # graph edges
|
|
5
|
+
Feed:
|
|
6
|
+
name: Feed
|
|
7
|
+
node_in: feed
|
|
8
|
+
node_out: screen
|
|
9
|
+
Coarse:
|
|
10
|
+
name: Coarse
|
|
11
|
+
node_in: screen
|
|
12
|
+
node_out: lump
|
|
13
|
+
Fine:
|
|
14
|
+
name: Fine
|
|
15
|
+
node_in: screen
|
|
16
|
+
node_out: fines
|
|
17
|
+
operations: # graph nodes
|
|
18
|
+
feed:
|
|
19
|
+
name: feed
|
|
20
|
+
screen:
|
|
21
|
+
name: screen
|
|
22
|
+
type: PartitionOperation
|
|
23
|
+
partition:
|
|
24
|
+
module: elphick.geomet.utils.partition
|
|
25
|
+
function: napier_munn_size_1mm
|
|
26
|
+
partition_stream: Lump # the stream that the partition model defines
|
|
27
|
+
args: null # e.g. d50, ep if not defined in the (partial) function
|
|
28
|
+
lump:
|
|
29
|
+
name: lump
|
|
30
|
+
fines:
|
|
31
|
+
name: fines
|