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.
Files changed (49) hide show
  1. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/PKG-INFO +1 -1
  2. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/__init__.py +0 -1
  3. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/base.py +333 -13
  4. geometallurgy-0.4.12/elphick/geomet/config/flowsheet_example_partition.yaml +31 -0
  5. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/mc_config.yml +1 -0
  6. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/flowsheet.py +220 -44
  7. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/operation.py +63 -10
  8. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/stream.py +14 -12
  9. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/interval_sample.py +110 -32
  10. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/block_model_converter.py +5 -4
  11. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/components.py +1 -1
  12. geometallurgy-0.4.12/elphick/geomet/utils/estimates.py +108 -0
  13. geometallurgy-0.4.12/elphick/geomet/utils/interp.py +193 -0
  14. geometallurgy-0.4.12/elphick/geomet/utils/interp2.py +134 -0
  15. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/pandas.py +25 -30
  16. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/partition.py +18 -0
  17. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/size.py +2 -2
  18. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/timer.py +1 -1
  19. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/pyproject.toml +8 -2
  20. geometallurgy-0.4.4/elphick/geomet/utils/interp.py +0 -107
  21. geometallurgy-0.4.4/elphick/geomet/utils/sampling.py +0 -5
  22. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/LICENSE +0 -0
  23. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/README.md +0 -0
  24. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/block_model.py +0 -0
  25. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/__init__.py +0 -0
  26. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/config/config_read.py +0 -0
  27. /geometallurgy-0.4.4/elphick/geomet/config/flowsheet_example.yaml → /geometallurgy-0.4.12/elphick/geomet/config/flowsheet_example_simple.yaml +0 -0
  28. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/data/downloader.py +0 -0
  29. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/data/register.csv +0 -0
  30. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/__init__.py +0 -0
  31. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/datasets.py +0 -0
  32. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/downloader.py +0 -0
  33. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/register.csv +0 -0
  34. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/datasets/sample_data.py +0 -0
  35. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/extras.py +0 -0
  36. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/__init__.py +0 -0
  37. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/flowsheet/loader.py +0 -0
  38. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/io.py +0 -0
  39. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/plot.py +0 -0
  40. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/profile.py +0 -0
  41. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/sample.py +0 -0
  42. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/__init__.py +0 -0
  43. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/amenability.py +0 -0
  44. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/data.py +0 -0
  45. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/layout.py +0 -0
  46. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/moisture.py +0 -0
  47. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/parallel.py +0 -0
  48. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/utils/viz.py +0 -0
  49. {geometallurgy-0.4.4 → geometallurgy-0.4.12}/elphick/geomet/validate.py.hide +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geometallurgy
3
- Version: 0.4.4
3
+ Version: 0.4.12
4
4
  Summary: Tools for the geometallurgist
5
5
  Home-page: https://github.com/elphick/geometallurgy
6
6
  Author: Greg
@@ -3,7 +3,6 @@ from importlib import metadata
3
3
 
4
4
  from .sample import Sample
5
5
  from .interval_sample import IntervalSample
6
- from .block_model import BlockModel
7
6
 
8
7
  try:
9
8
  __version__ = metadata.version('geometallurgy')
@@ -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, Any
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
- else:
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], random_int()]
533
- comp.nodes = [self.nodes[1], random_int()]
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], random_int()]
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
- res.nodes = [self.nodes[1], random_int()]
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
@@ -27,6 +27,7 @@ MC:
27
27
  - [from, to]
28
28
  - [retained, passing]
29
29
  - [sink, float]
30
+ - [lo, hi]
30
31
  comparisons:
31
32
  recovery: 'rec'
32
33
  difference: 'diff'