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,136 +1,136 @@
1
- """
2
- Managing components/composition
3
- """
4
-
5
- from typing import List, Dict, Union
6
-
7
- import periodictable as pt
8
- from periodictable.formulas import Formula
9
-
10
- custom_components: List[str] = ['LOI', 'Ash']
11
-
12
- # Kudos: pyrolite
13
- DEFAULT_CHARGES: Dict = dict(
14
- H=1,
15
- Li=1,
16
- Be=1,
17
- B=3,
18
- C=4,
19
- O=-2,
20
- F=-1,
21
- Na=1,
22
- Mg=2,
23
- Al=3,
24
- Si=4,
25
- P=3,
26
- Cl=-1,
27
- K=1,
28
- Ca=2,
29
- Sc=3,
30
- Ti=4,
31
- V=3,
32
- Cr=3,
33
- Mn=2,
34
- Fe=2,
35
- Co=2,
36
- Ni=2,
37
- Cu=2,
38
- Zn=2,
39
- Br=-1,
40
- Rb=1,
41
- Sr=2,
42
- Y=3,
43
- Zr=4,
44
- Nb=5,
45
- Sn=4,
46
- I=-1,
47
- Cs=1,
48
- Ba=2,
49
- La=3,
50
- Ce=3,
51
- Pr=3,
52
- Nd=3,
53
- Sm=3,
54
- Eu=3,
55
- Gd=3,
56
- Tb=3,
57
- Dy=3,
58
- Ho=3,
59
- Er=3,
60
- Tm=3,
61
- Yb=3,
62
- Lu=3,
63
- Hf=4,
64
- Pb=2,
65
- Th=4,
66
- U=4,
67
- )
68
-
69
-
70
- def elements() -> List[str]:
71
- res: List[str] = [el.symbol for el in pt.elements]
72
- return res
73
-
74
-
75
- def is_element(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
76
- if strict:
77
- matches: list = list(set(candidates).intersection(elements()))
78
- else:
79
- e_map: Dict[str, str] = {e.symbol.lower(): e.symbol for e in pt.elements}
80
- matches: Dict[str, str] = {c: e_map[c.lower()] for c in candidates if c.lower() in e_map.keys()}
81
-
82
- return matches
83
-
84
-
85
- def oxides() -> List[Formula]:
86
- # cats = {e for e in [el for el in pt.elements if str(el) in DEFAULT_CHARGES.keys()] if DEFAULT_CHARGES[str(e)] > 0}
87
- cats = {el for el in pt.elements if (str(el) in DEFAULT_CHARGES.keys()) and (DEFAULT_CHARGES[str(el)] > 0)}
88
-
89
- res: List[Formula] = []
90
- for c in cats:
91
- charge = DEFAULT_CHARGES[str(c)]
92
- if charge % 2 == 0:
93
- res.append(pt.formula(str(c) + str(1) + 'O' + str(charge // 2)))
94
- else:
95
- res.append(pt.formula(str(c) + str(2) + 'O' + str(charge)))
96
-
97
- return res
98
-
99
-
100
- def is_oxide(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
101
- if strict:
102
- oxs = {str(o) for o in oxides()}
103
- matches: list = list(set(candidates).intersection(oxs))
104
- else:
105
- o_map: Dict[str, str] = {str(o).lower(): str(o) for o in oxides()}
106
- matches: Dict[str, str] = {c: o_map[c.lower()] for c in candidates if c.lower() in o_map.keys()}
107
-
108
- return matches
109
-
110
-
111
- def is_compositional(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
112
- """
113
- Check if a list of candidates are compositional components (elements or oxides)
114
- Args:
115
- candidates: list of string candidates
116
- strict: If True, the candidates must be in the list of known compositional components (elements or oxides)
117
- as chemical symbols.
118
-
119
- Returns:
120
- If strict, a list of compositional components, otherwise a dict of the original candidates (keys) and
121
- their compositional component symbols (values)
122
- """
123
- if strict:
124
- comps = {str(o) for o in oxides()}.union(set(elements())).union(set(custom_components))
125
- matches: list = list(set(candidates).intersection(comps))
126
- else:
127
- comp_map: Dict[str, str] = {**{str(o).lower(): str(o) for o in oxides()},
128
- **{a.lower(): a for a in elements()},
129
- **{c.lower(): c for c in custom_components}}
130
- matches: Dict[str, str] = {c: comp_map[c.lower()] for c in candidates if c.lower() in comp_map.keys()}
131
-
132
- return matches
133
-
134
-
135
- def get_components(candidates: List[str], strict: bool = True) -> list[str]:
136
- return list(is_compositional(candidates, strict=strict).keys())
1
+ """
2
+ Managing components/composition
3
+ """
4
+
5
+ from typing import List, Dict, Union
6
+
7
+ import periodictable as pt
8
+ from periodictable.formulas import Formula
9
+
10
+ custom_components: List[str] = ['LOI', 'Ash']
11
+
12
+ # Kudos: pyrolite
13
+ DEFAULT_CHARGES: Dict = dict(
14
+ H=1,
15
+ Li=1,
16
+ Be=1,
17
+ B=3,
18
+ C=4,
19
+ O=-2,
20
+ F=-1,
21
+ Na=1,
22
+ Mg=2,
23
+ Al=3,
24
+ Si=4,
25
+ P=3,
26
+ Cl=-1,
27
+ K=1,
28
+ Ca=2,
29
+ Sc=3,
30
+ Ti=4,
31
+ V=3,
32
+ Cr=3,
33
+ Mn=2,
34
+ Fe=2,
35
+ Co=2,
36
+ Ni=2,
37
+ Cu=2,
38
+ Zn=2,
39
+ Br=-1,
40
+ Rb=1,
41
+ Sr=2,
42
+ Y=3,
43
+ Zr=4,
44
+ Nb=5,
45
+ Sn=4,
46
+ I=-1,
47
+ Cs=1,
48
+ Ba=2,
49
+ La=3,
50
+ Ce=3,
51
+ Pr=3,
52
+ Nd=3,
53
+ Sm=3,
54
+ Eu=3,
55
+ Gd=3,
56
+ Tb=3,
57
+ Dy=3,
58
+ Ho=3,
59
+ Er=3,
60
+ Tm=3,
61
+ Yb=3,
62
+ Lu=3,
63
+ Hf=4,
64
+ Pb=2,
65
+ Th=4,
66
+ U=4,
67
+ )
68
+
69
+
70
+ def elements() -> List[str]:
71
+ res: List[str] = [el.symbol for el in pt.elements]
72
+ return res
73
+
74
+
75
+ def is_element(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
76
+ if strict:
77
+ matches: list = list(set(candidates).intersection(elements()))
78
+ else:
79
+ e_map: Dict[str, str] = {e.symbol.lower(): e.symbol for e in pt.elements}
80
+ matches: Dict[str, str] = {c: e_map[c.lower()] for c in candidates if c.lower() in e_map.keys()}
81
+
82
+ return matches
83
+
84
+
85
+ def oxides() -> List[Formula]:
86
+ # cats = {e for e in [el for el in pt.elements if str(el) in DEFAULT_CHARGES.keys()] if DEFAULT_CHARGES[str(e)] > 0}
87
+ cats = {el for el in pt.elements if (str(el) in DEFAULT_CHARGES.keys()) and (DEFAULT_CHARGES[str(el)] > 0)}
88
+
89
+ res: List[Formula] = []
90
+ for c in cats:
91
+ charge = DEFAULT_CHARGES[str(c)]
92
+ if charge % 2 == 0:
93
+ res.append(pt.formula(str(c) + str(1) + 'O' + str(charge // 2)))
94
+ else:
95
+ res.append(pt.formula(str(c) + str(2) + 'O' + str(charge)))
96
+
97
+ return res
98
+
99
+
100
+ def is_oxide(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
101
+ if strict:
102
+ oxs = {str(o) for o in oxides()}
103
+ matches: list = list(set(candidates).intersection(oxs))
104
+ else:
105
+ o_map: Dict[str, str] = {str(o).lower(): str(o) for o in oxides()}
106
+ matches: Dict[str, str] = {c: o_map[c.lower()] for c in candidates if c.lower() in o_map.keys()}
107
+
108
+ return matches
109
+
110
+
111
+ def is_compositional(candidates: List[str], strict: bool = True) -> Union[List[str], Dict[str, str]]:
112
+ """
113
+ Check if a list of candidates are compositional components (elements or oxides)
114
+ Args:
115
+ candidates: list of string candidates
116
+ strict: If True, the candidates must be in the list of known compositional components (elements or oxides)
117
+ as chemical symbols.
118
+
119
+ Returns:
120
+ If strict, a list of compositional components, otherwise a dict of the original candidates (keys) and
121
+ their compositional component symbols (values)
122
+ """
123
+ if strict:
124
+ comps = {str(o) for o in oxides()}.union(set(elements())).union(set(custom_components))
125
+ matches: list = list(set(candidates).intersection(comps))
126
+ else:
127
+ comp_map: Dict[str, str] = {**{str(o).lower(): str(o) for o in oxides()},
128
+ **{a.lower(): a for a in elements()},
129
+ **{c.lower(): c for c in custom_components}}
130
+ matches: Dict[str, str] = {c: comp_map[c.lower()] for c in candidates if c.lower() in comp_map.keys()}
131
+
132
+ return matches
133
+
134
+
135
+ def get_components(candidates: List[str], strict: bool = True) -> list[str]:
136
+ return list(is_compositional(candidates, strict=strict).keys())
@@ -1,49 +1,49 @@
1
- import pandas as pd
2
-
3
-
4
- def sample_data(include_wet_mass: bool = True, include_dry_mass: bool = True,
5
- include_moisture: bool = False, include_chem_vars: bool = True) -> pd.DataFrame:
6
- """Creates synthetic data for testing
7
-
8
- Args:
9
- include_wet_mass: If True, wet mass is included.
10
- include_dry_mass: If True, dry mass is included.
11
- include_moisture: If True, moisture (H2O) is included.
12
- include_chem_vars: If True, chemical variables are included.
13
-
14
- Returns:
15
-
16
- """
17
-
18
- # mass_wet: pd.Series = pd.Series([100, 90, 110], name='wet_mass')
19
- # mass_dry: pd.Series = pd.Series([90, 80, 100], name='dry_mass')
20
- mass_wet: pd.Series = pd.Series([100., 90., 110.], name='wet_mass')
21
- mass_dry: pd.Series = pd.Series([90., 80., 90.], name='mass_dry')
22
- chem: pd.DataFrame = pd.DataFrame.from_dict({'FE': [57., 59., 61.],
23
- 'SIO2': [5.2, 3.1, 2.2],
24
- 'al2o3': [3.0, 1.7, 0.9],
25
- 'LOI': [5.0, 4.0, 3.0]})
26
- attrs: pd.Series = pd.Series(['grp_1', 'grp_1', 'grp_2'], name='group')
27
-
28
- if include_wet_mass and not include_dry_mass:
29
- mass = pd.DataFrame(mass_wet)
30
- elif not include_wet_mass and include_dry_mass:
31
- mass = pd.DataFrame(mass_dry)
32
- elif include_wet_mass and include_dry_mass:
33
- mass = pd.concat([mass_wet, mass_dry], axis='columns')
34
- else:
35
- raise AssertionError('Arguments provided result in no mass column')
36
-
37
- if include_moisture is True:
38
- moisture: pd.DataFrame = (mass_wet - mass_dry) / mass_wet * 100
39
- moisture.name = 'H2O'
40
- res: pd.DataFrame = pd.concat([mass, moisture, chem, attrs], axis='columns')
41
- else:
42
- res: pd.DataFrame = pd.concat([mass, chem, attrs], axis='columns')
43
-
44
- if include_chem_vars is False:
45
- res = res.drop(columns=chem.columns)
46
-
47
- res.index.name = 'index'
48
-
49
- return res
1
+ import pandas as pd
2
+
3
+
4
+ def sample_data(include_wet_mass: bool = True, include_dry_mass: bool = True,
5
+ include_moisture: bool = False, include_chem_vars: bool = True) -> pd.DataFrame:
6
+ """Creates synthetic data for testing
7
+
8
+ Args:
9
+ include_wet_mass: If True, wet mass is included.
10
+ include_dry_mass: If True, dry mass is included.
11
+ include_moisture: If True, moisture (H2O) is included.
12
+ include_chem_vars: If True, chemical variables are included.
13
+
14
+ Returns:
15
+
16
+ """
17
+
18
+ # mass_wet: pd.Series = pd.Series([100, 90, 110], name='wet_mass')
19
+ # mass_dry: pd.Series = pd.Series([90, 80, 100], name='dry_mass')
20
+ mass_wet: pd.Series = pd.Series([100., 90., 110.], name='wet_mass')
21
+ mass_dry: pd.Series = pd.Series([90., 80., 90.], name='mass_dry')
22
+ chem: pd.DataFrame = pd.DataFrame.from_dict({'FE': [57., 59., 61.],
23
+ 'SIO2': [5.2, 3.1, 2.2],
24
+ 'al2o3': [3.0, 1.7, 0.9],
25
+ 'LOI': [5.0, 4.0, 3.0]})
26
+ attrs: pd.Series = pd.Series(['grp_1', 'grp_1', 'grp_2'], name='group')
27
+
28
+ if include_wet_mass and not include_dry_mass:
29
+ mass = pd.DataFrame(mass_wet)
30
+ elif not include_wet_mass and include_dry_mass:
31
+ mass = pd.DataFrame(mass_dry)
32
+ elif include_wet_mass and include_dry_mass:
33
+ mass = pd.concat([mass_wet, mass_dry], axis='columns')
34
+ else:
35
+ raise AssertionError('Arguments provided result in no mass column')
36
+
37
+ if include_moisture is True:
38
+ moisture: pd.DataFrame = (mass_wet - mass_dry) / mass_wet * 100
39
+ moisture.name = 'H2O'
40
+ res: pd.DataFrame = pd.concat([mass, moisture, chem, attrs], axis='columns')
41
+ else:
42
+ res: pd.DataFrame = pd.concat([mass, chem, attrs], axis='columns')
43
+
44
+ if include_chem_vars is False:
45
+ res = res.drop(columns=chem.columns)
46
+
47
+ res.index.name = 'index'
48
+
49
+ return res
@@ -1,108 +1,108 @@
1
- import logging
2
-
3
- import pandas as pd
4
-
5
- from elphick.geomet.base import MassComposition
6
- from elphick.geomet.flowsheet import Flowsheet
7
- from elphick.geomet.flowsheet.stream import Stream
8
- from elphick.geomet.utils.moisture import solve_mass_moisture
9
- from elphick.geomet.utils.pandas import composition_to_mass
10
-
11
-
12
- def coerce_estimates(estimate_stream: Stream, input_stream: Stream,
13
- recovery_bounds: tuple[float, float] = (0.01, 0.99),
14
- complement_name: str = 'complement',
15
- fs_name: str = 'Flowsheet',
16
- show_plot: bool = False) -> Stream:
17
- """Coerce output estimates within recovery and the component range.
18
-
19
- Estimates contain error and at times can exceed the specified component range, or can consume more component
20
- mass than is available in the feed. This function modifies (coerces) only the non-compliant estimate records
21
- in order to balance the node and keep all dry components within range. Moisture is not modified.
22
-
23
- estimate_stream (supplied)
24
- /
25
- input_stream (supplied)
26
- \
27
- complement_stream
28
-
29
- 1. limits the estimate to within the recovery bounds,
30
- 2. ensures the estimate is within the component range,
31
- 3. solves the complement, and ensures it is in range,
32
- 4. if the complement is out of range, it is adjusted and the estimate adjusted to maintain the balance.
33
-
34
- Args:
35
- estimate_stream: The estimated object, which is a node output
36
- input_stream: The input object, which is a node input
37
- recovery_bounds: The bounds for the recovery, default is 0.01 to 0.99
38
- complement_name: The name of the complement , for plots.
39
- fs_name: The name of the flowsheet, for plots.
40
- show_plot: If True, show the network plot
41
-
42
- Returns:
43
- The coerced estimate stream
44
- """
45
-
46
- if show_plot:
47
- complement_stream: MassComposition = input_stream.sub(estimate_stream, name=complement_name)
48
- fs: Flowsheet = Flowsheet.from_objects([input_stream, estimate_stream, complement_stream],
49
- name=f"{fs_name}: Balance prior to coercion")
50
- fs.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
51
-
52
- # # debugging snippet to show a failing record
53
- # qry: str = 'index==1000'
54
- # fs_debug: Flowsheet = fs.query(qry)
55
- # fs_debug.name = f"{fs_name}: Balance prior to coercion: [{qry}]"
56
- # fs_debug.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
57
-
58
- if input_stream.status.ok is False:
59
- raise ValueError('Input stream is not OK')
60
-
61
- # clip the composition to the bounds
62
- estimate_stream = estimate_stream.clip_composition()
63
-
64
- # coerce the estimate component mass to within the total dry mass
65
- estimate_stream = estimate_stream.balance_composition()
66
-
67
- # clip the recovery
68
- estimate_stream = estimate_stream.clip_recovery(other=input_stream, recovery_bounds=(0.01, 0.99))
69
-
70
- if estimate_stream.status.ok is False:
71
- raise ValueError('Estimate stream is not OK - it should be after bounding recovery')
72
-
73
- # solve the complement
74
- complement_stream: Stream = input_stream.sub(estimate_stream, name=complement_name)
75
-
76
- # clip the composition to the bounds
77
- complement_stream = complement_stream.clip_composition()
78
-
79
- # coerce the estimate component mass to within the total dry mass
80
- complement_stream = complement_stream.balance_composition()
81
-
82
- # adjust the estimate to maintain the balance
83
- estimate_stream = input_stream.sub(complement_stream, name=estimate_stream.name,
84
- include_supplementary_data=True)
85
-
86
- if estimate_stream.status.ok is False:
87
- # This can occur in cases where the complement grade has been reduced (by balance_composition) to a point
88
- # where the resultant estimate grade is out of range. In this case, we need to adjust the complement grade.
89
-
90
- estimate_stream = estimate_stream.clip_composition()
91
- complement_stream = input_stream.sub(estimate_stream, name=complement_name)
92
-
93
- if estimate_stream.status.ok is False:
94
- raise ValueError('Estimate stream is not OK after adjustment')
95
-
96
- fs2: Flowsheet = Flowsheet.from_objects([input_stream, estimate_stream, complement_stream],
97
- name=f"{fs_name}: Coerced Estimates")
98
-
99
- if show_plot:
100
- fs2.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
101
-
102
- if fs2.all_nodes_healthy is False:
103
- if fs2.all_streams_healthy and not fs2.all_nodes_healthy:
104
- logging.warning('All streams are healthy but not all nodes are healthy. Consider the water balance.')
105
- else:
106
- raise ValueError('Flowsheet is not balanced after adjustment')
107
-
108
- return estimate_stream
1
+ import logging
2
+
3
+ import pandas as pd
4
+
5
+ from elphick.geomet.base import MassComposition
6
+ from elphick.geomet.flowsheet import Flowsheet
7
+ from elphick.geomet.flowsheet.stream import Stream
8
+ from elphick.geomet.utils.moisture import solve_mass_moisture
9
+ from elphick.geomet.utils.pandas import composition_to_mass
10
+
11
+
12
+ def coerce_estimates(estimate_stream: Stream, input_stream: Stream,
13
+ recovery_bounds: tuple[float, float] = (0.01, 0.99),
14
+ complement_name: str = 'complement',
15
+ fs_name: str = 'Flowsheet',
16
+ show_plot: bool = False) -> Stream:
17
+ """Coerce output estimates within recovery and the component range.
18
+
19
+ Estimates contain error and at times can exceed the specified component range, or can consume more component
20
+ mass than is available in the feed. This function modifies (coerces) only the non-compliant estimate records
21
+ in order to balance the node and keep all dry components within range. Moisture is not modified.
22
+
23
+ estimate_stream (supplied)
24
+ /
25
+ input_stream (supplied)
26
+ \
27
+ complement_stream
28
+
29
+ 1. limits the estimate to within the recovery bounds,
30
+ 2. ensures the estimate is within the component range,
31
+ 3. solves the complement, and ensures it is in range,
32
+ 4. if the complement is out of range, it is adjusted and the estimate adjusted to maintain the balance.
33
+
34
+ Args:
35
+ estimate_stream: The estimated object, which is a node output
36
+ input_stream: The input object, which is a node input
37
+ recovery_bounds: The bounds for the recovery, default is 0.01 to 0.99
38
+ complement_name: The name of the complement , for plots.
39
+ fs_name: The name of the flowsheet, for plots.
40
+ show_plot: If True, show the network plot
41
+
42
+ Returns:
43
+ The coerced estimate stream
44
+ """
45
+
46
+ if show_plot:
47
+ complement_stream: MassComposition = input_stream.sub(estimate_stream, name=complement_name)
48
+ fs: Flowsheet = Flowsheet.from_objects([input_stream, estimate_stream, complement_stream],
49
+ name=f"{fs_name}: Balance prior to coercion")
50
+ fs.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
51
+
52
+ # # debugging snippet to show a failing record
53
+ # qry: str = 'index==1000'
54
+ # fs_debug: Flowsheet = fs.query(qry)
55
+ # fs_debug.name = f"{fs_name}: Balance prior to coercion: [{qry}]"
56
+ # fs_debug.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
57
+
58
+ if input_stream.status.ok is False:
59
+ raise ValueError('Input stream is not OK')
60
+
61
+ # clip the composition to the bounds
62
+ estimate_stream = estimate_stream.clip_composition()
63
+
64
+ # coerce the estimate component mass to within the total dry mass
65
+ estimate_stream = estimate_stream.balance_composition()
66
+
67
+ # clip the recovery
68
+ estimate_stream = estimate_stream.clip_recovery(other=input_stream, recovery_bounds=(0.01, 0.99))
69
+
70
+ if estimate_stream.status.ok is False:
71
+ raise ValueError('Estimate stream is not OK - it should be after bounding recovery')
72
+
73
+ # solve the complement
74
+ complement_stream: Stream = input_stream.sub(estimate_stream, name=complement_name)
75
+
76
+ # clip the composition to the bounds
77
+ complement_stream = complement_stream.clip_composition()
78
+
79
+ # coerce the estimate component mass to within the total dry mass
80
+ complement_stream = complement_stream.balance_composition()
81
+
82
+ # adjust the estimate to maintain the balance
83
+ estimate_stream = input_stream.sub(complement_stream, name=estimate_stream.name,
84
+ include_supplementary_data=True)
85
+
86
+ if estimate_stream.status.ok is False:
87
+ # This can occur in cases where the complement grade has been reduced (by balance_composition) to a point
88
+ # where the resultant estimate grade is out of range. In this case, we need to adjust the complement grade.
89
+
90
+ estimate_stream = estimate_stream.clip_composition()
91
+ complement_stream = input_stream.sub(estimate_stream, name=complement_name)
92
+
93
+ if estimate_stream.status.ok is False:
94
+ raise ValueError('Estimate stream is not OK after adjustment')
95
+
96
+ fs2: Flowsheet = Flowsheet.from_objects([input_stream, estimate_stream, complement_stream],
97
+ name=f"{fs_name}: Coerced Estimates")
98
+
99
+ if show_plot:
100
+ fs2.table_plot(plot_type='network', table_area=0.2, table_pos='top').show()
101
+
102
+ if fs2.all_nodes_healthy is False:
103
+ if fs2.all_streams_healthy and not fs2.all_nodes_healthy:
104
+ logging.warning('All streams are healthy but not all nodes are healthy. Consider the water balance.')
105
+ else:
106
+ raise ValueError('Flowsheet is not balanced after adjustment')
107
+
108
+ return estimate_stream