geometallurgy 0.4.8__tar.gz → 0.4.10__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.8 → geometallurgy-0.4.10}/PKG-INFO +1 -1
  2. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/__init__.py +0 -1
  3. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/base.py +166 -13
  4. geometallurgy-0.4.10/elphick/geomet/config/flowsheet_example_partition.yaml +31 -0
  5. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/flowsheet/flowsheet.py +187 -37
  6. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/flowsheet/operation.py +63 -10
  7. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/flowsheet/stream.py +8 -7
  8. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/interval_sample.py +106 -29
  9. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/block_model_converter.py +5 -4
  10. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/components.py +1 -1
  11. geometallurgy-0.4.10/elphick/geomet/utils/estimates.py +108 -0
  12. geometallurgy-0.4.10/elphick/geomet/utils/interp.py +193 -0
  13. geometallurgy-0.4.10/elphick/geomet/utils/interp2.py +134 -0
  14. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/pandas.py +24 -29
  15. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/partition.py +18 -0
  16. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/size.py +2 -2
  17. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/pyproject.toml +3 -2
  18. geometallurgy-0.4.8/elphick/geomet/utils/interp.py +0 -107
  19. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/LICENSE +0 -0
  20. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/README.md +0 -0
  21. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/block_model.py +0 -0
  22. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/config/__init__.py +0 -0
  23. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/config/config_read.py +0 -0
  24. /geometallurgy-0.4.8/elphick/geomet/config/flowsheet_example.yaml → /geometallurgy-0.4.10/elphick/geomet/config/flowsheet_example_simple.yaml +0 -0
  25. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/config/mc_config.yml +0 -0
  26. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/data/downloader.py +0 -0
  27. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/data/register.csv +0 -0
  28. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/datasets/__init__.py +0 -0
  29. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/datasets/datasets.py +0 -0
  30. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/datasets/downloader.py +0 -0
  31. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/datasets/register.csv +0 -0
  32. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/datasets/sample_data.py +0 -0
  33. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/extras.py +0 -0
  34. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/flowsheet/__init__.py +0 -0
  35. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/flowsheet/loader.py +0 -0
  36. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/io.py +0 -0
  37. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/plot.py +0 -0
  38. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/profile.py +0 -0
  39. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/sample.py +0 -0
  40. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/__init__.py +0 -0
  41. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/amenability.py +0 -0
  42. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/data.py +0 -0
  43. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/layout.py +0 -0
  44. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/moisture.py +0 -0
  45. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/parallel.py +0 -0
  46. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/sampling.py +0 -0
  47. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/timer.py +0 -0
  48. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/elphick/geomet/utils/viz.py +0 -0
  49. {geometallurgy-0.4.8 → geometallurgy-0.4.10}/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.8
3
+ Version: 0.4.10
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')
@@ -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:
@@ -229,19 +230,24 @@ class MassComposition(ABC):
229
230
  (self.mass_columns + [self.moisture_column] + self.composition_columns + self.supplementary_columns) if
230
231
  col is not None]
231
232
 
232
- def balance_composition(self) -> MC:
233
+ def balance_composition(self, epsilon: float = 1.0e-6) -> MC:
233
234
  """Balance the composition data
234
235
 
235
236
  For records where the component mass exceeds the dry mass, the component masses are reduced proportionally
236
237
  to equal the dry mass. Records where the component mass is less than the dry mass are left unchanged.
237
238
 
239
+ epsilon: A small float to avoid component sums marginally over 100.0
240
+
241
+ Returns:
242
+ The object with balanced composition.
238
243
  """
239
244
  if self._mass_data is not None:
240
245
  # calculate the ratio of the sum of the components to the dry mass
241
246
  ratio = self._mass_data[self.composition_columns].sum(axis=1) / self._mass_data[self.mass_dry_var]
242
247
  if ratio.max() <= 1.0:
243
248
  return self
244
- epsilon = 1e-6
249
+
250
+ before_reduction = self._mass_data[self.composition_columns].copy()
245
251
  # add a small value to the ratio to avoid component sums marginally over 100.0
246
252
  ratio[ratio > 1.0] = ratio[ratio > 1.0] + epsilon
247
253
  # to avoid reducing compliant records, clip the ratio at the lower side to 1.0
@@ -249,6 +255,19 @@ class MassComposition(ABC):
249
255
  # apply the ratio to the components
250
256
  self._mass_data[self.composition_columns] = self._mass_data[self.composition_columns].div(ratio, axis=0)
251
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}")
252
271
  return self
253
272
 
254
273
  def clip_recovery(self, other: MC, recovery_bounds: tuple[float, float] = (0.01, 0.99),
@@ -325,9 +344,12 @@ class MassComposition(ABC):
325
344
  else:
326
345
  raise ValueError(f"mass_to_adjust must be 'wet' or 'dry', not {mass_to_adjust}")
327
346
 
347
+ self.aggregate = self.weight_average()
348
+ self.status = OutOfRangeStatus(self, self.status.ranges)
349
+
328
350
  return self
329
351
 
330
- def clip_composition(self, ranges: Optional[dict[str, list[float]]] = None) -> MC:
352
+ def clip_composition(self, ranges: Optional[dict[str, list[float]]] = None, epsilon: float = 1.0e-05) -> MC:
331
353
  """Clip the components
332
354
 
333
355
  Clip to the components to within the range provided or the default range for each component.
@@ -336,6 +358,7 @@ class MassComposition(ABC):
336
358
  Args:
337
359
  ranges: An optional dict defining a list of [lo, hi] floats for each component. If not provided,
338
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.
339
362
 
340
363
  Returns:
341
364
  The object with clipped composition.
@@ -345,24 +368,31 @@ class MassComposition(ABC):
345
368
  component_ranges: dict = self._get_component_ranges(ranges)
346
369
 
347
370
  # define a small value to ensure the clipped values lie marginally inside the specified range.
348
- epsilon: float = 0.0 # 1.0e-05
349
371
  # clip the components
350
372
  affected_indexes = set()
351
373
  for component, component_range in component_ranges.items():
352
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]
353
377
  # define the component mass that aligns with the lower and upper bounds
354
378
  component_mass_limits = self._mass_data[self.mass_dry_var].values[:, np.newaxis] * np.array(
355
379
  component_range) / self.composition_factor
356
380
  # apply the clip to the mass data
357
- self._mass_data[component] = self._mass_data[component].clip(lower=component_mass_limits[:, 0] + epsilon,
358
- upper=component_mass_limits[:, 1] - epsilon)
381
+ self._mass_data[component] = self._mass_data[component].clip(lower=component_mass_limits[:, 0],
382
+ upper=component_mass_limits[:, 1])
359
383
  affected_indexes.update(self._mass_data.index[before_clip != self._mass_data[component]])
360
384
 
361
385
  # log the action, including the first 50 indexes affected
362
386
  affected_indexes_list = sorted(affected_indexes)[:50]
363
- self._logger.info(
364
- f"{len(affected_indexes)} records where composition has been clipped to the range: {component_ranges}."
365
- f" Affected indexes (first 50): {affected_indexes_list}")
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}")
366
396
 
367
397
  return self
368
398
 
@@ -505,7 +535,8 @@ class MassComposition(ABC):
505
535
 
506
536
  # Create a DataFrame from the weighted averages
507
537
  weighted_averages_df = pd.concat([mass_sum, composition], axis=1)
508
- else:
538
+
539
+ else: # group by a variable
509
540
  group_var: pd.Series = self._supplementary_data[group_by]
510
541
  weighted_averages_df = self._mass_data.groupby(group_var).apply(
511
542
  lambda x: pd.DataFrame(
@@ -600,7 +631,7 @@ class MassComposition(ABC):
600
631
  non_mass_cols: list[str] = [col for col in value.columns if
601
632
  col not in [self.mass_wet_var, self.mass_dry_var, self.moisture_var, 'h2o',
602
633
  'H2O', 'H2O']]
603
- 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)
604
635
  else:
605
636
  component_cols: list[str] = self.component_vars
606
637
  composition = value[component_cols]
@@ -632,6 +663,7 @@ class MassComposition(ABC):
632
663
  self._supplementary_data.index = self._mass_data.index
633
664
  self._supplementary_data = self._supplementary_data.loc[value.index]
634
665
  self.aggregate = self.weight_average()
666
+ self.status = OutOfRangeStatus(self, self.status.ranges)
635
667
 
636
668
  def filter_by_index(self, index: pd.Index):
637
669
  """Update the data by index"""
@@ -702,6 +734,10 @@ class MassComposition(ABC):
702
734
 
703
735
  res: MC = self.create_congruent_object(name=name, include_mc_data=True,
704
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)}")
705
741
  res.update_mass_data(self._mass_data + other._mass_data)
706
742
 
707
743
  # Ensure self and other are Stream objects
@@ -729,6 +765,11 @@ class MassComposition(ABC):
729
765
  """
730
766
  res = self.create_congruent_object(name=name, include_mc_data=True,
731
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
+
732
773
  res.update_mass_data(self._mass_data - other._mass_data)
733
774
 
734
775
  # Ensure self and other are Stream objects
@@ -738,6 +779,7 @@ class MassComposition(ABC):
738
779
  other: 'Stream'
739
780
 
740
781
  # create the relationships
782
+ other.nodes = [self.nodes[1], other.nodes[1]]
741
783
  res.nodes = [self.nodes[1], random_int()]
742
784
 
743
785
  return res
@@ -890,6 +932,41 @@ class MassComposition(ABC):
890
932
 
891
933
  return res
892
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
+
893
970
  def reset_index(self, index_name: str) -> MC:
894
971
  res = self.create_congruent_object(name=f"{self.name} (reset_index)", include_mc_data=True,
895
972
  include_supp_data=True)
@@ -934,7 +1011,7 @@ class OutOfRangeStatus:
934
1011
  self.oor: pd.DataFrame = self._check_range()
935
1012
  self.num_oor: int = len(self.oor)
936
1013
  self.failing_components: Optional[list[str]] = list(
937
- 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
938
1015
 
939
1016
  def get_ranges(self, ranges: dict[str, list]) -> dict[str, list]:
940
1017
 
@@ -978,3 +1055,79 @@ class OutOfRangeStatus:
978
1055
  if isinstance(other, OutOfRangeStatus):
979
1056
  return self.oor.equals(other.oor)
980
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