geometallurgy 0.4.13__py3-none-any.whl → 0.4.15__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 -319
  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/pandas.py +378 -378
  36. elphick/geomet/utils/parallel.py +29 -29
  37. elphick/geomet/utils/partition.py +63 -63
  38. elphick/geomet/utils/size.py +51 -51
  39. elphick/geomet/utils/timer.py +80 -80
  40. elphick/geomet/utils/viz.py +56 -56
  41. elphick/geomet/validate.py.hide +176 -176
  42. {geometallurgy-0.4.13.dist-info → geometallurgy-0.4.15.dist-info}/LICENSE +21 -21
  43. {geometallurgy-0.4.13.dist-info → geometallurgy-0.4.15.dist-info}/METADATA +2 -3
  44. geometallurgy-0.4.15.dist-info/RECORD +48 -0
  45. {geometallurgy-0.4.13.dist-info → geometallurgy-0.4.15.dist-info}/WHEEL +1 -1
  46. elphick/geomet/utils/output.html +0 -617
  47. geometallurgy-0.4.13.dist-info/RECORD +0 -49
  48. {geometallurgy-0.4.13.dist-info → geometallurgy-0.4.15.dist-info}/entry_points.txt +0 -0
@@ -1,134 +1,134 @@
1
- from typing import Optional, Iterable, Union
2
-
3
- import numpy as np
4
- import pandas as pd
5
- from scipy.interpolate import pchip_interpolate, PchipInterpolator
6
-
7
- from elphick.geomet.utils.pandas import composition_to_mass, mass_to_composition, weight_average
8
-
9
-
10
- def mass_preserving_interp_2d(specific_mass_intervals: pd.DataFrame,
11
- interval_edges: Optional[dict[str, Iterable[float]]] = None,
12
- precision: Optional[int] = None,
13
- mass_dry: str = 'mass_dry') -> pd.DataFrame:
14
- """Interpolate 2D interval data with zero mass loss using pchip
15
-
16
- This function applies mass-preserving up-sampling to 2D interval data. The function will:
17
- For example if the first dimension is size and the second is density:
18
-
19
- 1. Convert to specific mass
20
- 2. Accumulate along both dimensions
21
- 3. For every first (size) dimension value
22
- a. fit a monotonic spline for the values of the second (density) dimension
23
- b. interpolate the second (density) dimension to the supplied (density) grid
24
- 4. For every value on the second (density) upsampled (supplied) grid:
25
- a. fit a monotonic spline to the values of the first (size) dimension
26
- b. interpolate the first (size) dimension to the supplied (size) grid
27
- 5. Decumulate along both dimensions
28
- 6. Convert to composition
29
-
30
- The above steps are repeated for every component, starting with dry mass and ending with the last component.
31
-
32
-
33
- Args:
34
- specific_mass_intervals: Dataframe with two pd.IntervalIndexes, representing specific mass intervals.
35
- interval_edges: Dict of the values of the new grid (interval edges) for each dimension, keyed by index
36
- name (dimension). If None, the grid will be a rectilinear grid using unique values from the index.
37
- precision: Number of decimal places to round the index (edge) values.
38
- mass_dry: The dry mass column, not optional. Consider solve_mass_moisture prior to this call if needed.
39
-
40
- Returns:
41
-
42
- """
43
-
44
- if precision is None:
45
- precision = 6
46
-
47
- dim_names = specific_mass_intervals.index.names
48
- original_dim1_vals: np.ndarray = np.unique(specific_mass_intervals.index.get_level_values(dim_names[0]).right)
49
- original_dim2_vals: np.ndarray = np.unique(specific_mass_intervals.index.get_level_values(dim_names[1]).right)
50
-
51
- specific_mass_intervals.sort_index(ascending=[True, True], inplace=True)
52
-
53
- if interval_edges is None:
54
- interval_edges: dict[str, list[float]] = {dim: specific_mass_intervals.index.get_level_values(dim).unique()
55
- for dim in dim_names}
56
-
57
- # check that the supplied interval edges are bounded on the left by the sampled minimum
58
- for dim in dim_names:
59
- if np.min(interval_edges[dim]) < specific_mass_intervals.index.get_level_values(dim).left.min():
60
- raise ValueError(f"The supplied {dim} grid contains values lower than the minimum in the sample.")
61
-
62
-
63
- # Convert to a numpy 3d array
64
- # [i, j, k] where i is the dim1, j is the dim2, and k is the component
65
-
66
- # We will accumulate along both dimensions, but notably we cannot assume that the 2d data sits on a
67
- # rectilinear grid.
68
-
69
- # TODO: test this code block in the case of non-rectilinear input data.
70
- mass_data: np.ndarray = specific_mass_intervals.to_numpy().reshape(
71
- specific_mass_intervals.index.get_level_values(dim_names[0]).unique().size,
72
- specific_mass_intervals.index.get_level_values(dim_names[1]).unique().size, -1)
73
-
74
- mass_data_cum: np.ndarray = np.cumsum(np.cumsum(mass_data, axis=0), axis=1)
75
-
76
- # create a 3D array to store the upsampled dim2 data (for each original dim1 value)
77
- upsampled_dim2: np.ndarray = np.zeros(
78
- (len(original_dim1_vals), len(interval_edges[dim_names[1]]), mass_data_cum.shape[2]))
79
-
80
- # create a 3D array to store the full upsampled (dim1 and dim2) data
81
- mass_data_upsampled = np.zeros(
82
- (len(interval_edges[dim_names[0]]), len(interval_edges[dim_names[1]]), mass_data_cum.shape[2]))
83
-
84
- for k in range(mass_data_cum.shape[2]): # for every component (c)
85
-
86
- for i in range(mass_data_cum.shape[0]): # for every dim1 (i), upsample dim2 (j)
87
- x = original_dim2_vals # dim2
88
- y = mass_data_cum[i, :, k] # component
89
-
90
- dim2_spline = PchipInterpolator(x, y, extrapolate=True)
91
- # now we can upsample the spline to a finer grid
92
- y_new = dim2_spline(interval_edges[dim_names[1]]) # upsampled dim2
93
- # Store the upsampled data
94
- upsampled_dim2[i, :, k] = y_new
95
-
96
- # for every upsampled dim2 value (j), upsample dim1 (i)
97
- for j in range(len(interval_edges[dim_names[1]])):
98
- x = original_dim1_vals # dim1
99
- y = upsampled_dim2[:, j, k] # component
100
-
101
- dim1_spline = PchipInterpolator(x, y, extrapolate=True)
102
- # now we can upsample the spline to a finer grid
103
- y_new = dim1_spline(interval_edges[dim_names[0]]) # upsampled size
104
- # Store the upsampled data
105
- mass_data_upsampled[:, j, k] = y_new
106
-
107
- print(mass_data_upsampled.shape)
108
- # Now we can decumulate along both dimensions
109
- mass_data_decum: np.ndarray = np.diff(
110
- np.diff(mass_data_upsampled, axis=0,
111
- prepend=np.zeros((1, mass_data_upsampled.shape[1], mass_data_upsampled.shape[2]))),
112
- axis=1, prepend=np.zeros((mass_data_upsampled.shape[0], 1, mass_data_upsampled.shape[2])))
113
-
114
- # Reshape to a 2D array
115
- mass_data_decum = mass_data_decum.reshape(mass_data_upsampled.shape[0] * mass_data_upsampled.shape[1],
116
- mass_data_upsampled.shape[2])
117
-
118
- # Create the multiindex of IntervalIndex for the upsampled data, using the supplied interval edges
119
-
120
- # Generate the MultiIndex
121
- min_left_edges = np.array([i.left for i in specific_mass_intervals.index[0]])
122
- dim1_indexes = pd.IntervalIndex.from_breaks(
123
- np.round(np.hstack((min_left_edges[0], interval_edges[dim_names[0]])), precision),
124
- closed='left')
125
- dim2_indexes = pd.IntervalIndex.from_breaks(
126
- np.round(np.hstack((min_left_edges[1], interval_edges[dim_names[1]])), precision),
127
- closed='left')
128
-
129
- index = pd.MultiIndex.from_product([dim1_indexes, dim2_indexes], names=dim_names)
130
-
131
- # TODO: account for uniform density for some sizes, like cyclosize.
132
- # This can be by 3 splines, one for mid, and the two boundaries, by extrapolation.
133
-
134
- return pd.DataFrame(mass_data_decum, index=index, columns=specific_mass_intervals.columns)
1
+ from typing import Optional, Iterable, Union
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from scipy.interpolate import pchip_interpolate, PchipInterpolator
6
+
7
+ from elphick.geomet.utils.pandas import composition_to_mass, mass_to_composition, weight_average
8
+
9
+
10
+ def mass_preserving_interp_2d(specific_mass_intervals: pd.DataFrame,
11
+ interval_edges: Optional[dict[str, Iterable[float]]] = None,
12
+ precision: Optional[int] = None,
13
+ mass_dry: str = 'mass_dry') -> pd.DataFrame:
14
+ """Interpolate 2D interval data with zero mass loss using pchip
15
+
16
+ This function applies mass-preserving up-sampling to 2D interval data. The function will:
17
+ For example if the first dimension is size and the second is density:
18
+
19
+ 1. Convert to specific mass
20
+ 2. Accumulate along both dimensions
21
+ 3. For every first (size) dimension value
22
+ a. fit a monotonic spline for the values of the second (density) dimension
23
+ b. interpolate the second (density) dimension to the supplied (density) grid
24
+ 4. For every value on the second (density) upsampled (supplied) grid:
25
+ a. fit a monotonic spline to the values of the first (size) dimension
26
+ b. interpolate the first (size) dimension to the supplied (size) grid
27
+ 5. Decumulate along both dimensions
28
+ 6. Convert to composition
29
+
30
+ The above steps are repeated for every component, starting with dry mass and ending with the last component.
31
+
32
+
33
+ Args:
34
+ specific_mass_intervals: Dataframe with two pd.IntervalIndexes, representing specific mass intervals.
35
+ interval_edges: Dict of the values of the new grid (interval edges) for each dimension, keyed by index
36
+ name (dimension). If None, the grid will be a rectilinear grid using unique values from the index.
37
+ precision: Number of decimal places to round the index (edge) values.
38
+ mass_dry: The dry mass column, not optional. Consider solve_mass_moisture prior to this call if needed.
39
+
40
+ Returns:
41
+
42
+ """
43
+
44
+ if precision is None:
45
+ precision = 6
46
+
47
+ dim_names = specific_mass_intervals.index.names
48
+ original_dim1_vals: np.ndarray = np.unique(specific_mass_intervals.index.get_level_values(dim_names[0]).right)
49
+ original_dim2_vals: np.ndarray = np.unique(specific_mass_intervals.index.get_level_values(dim_names[1]).right)
50
+
51
+ specific_mass_intervals.sort_index(ascending=[True, True], inplace=True)
52
+
53
+ if interval_edges is None:
54
+ interval_edges: dict[str, list[float]] = {dim: specific_mass_intervals.index.get_level_values(dim).unique()
55
+ for dim in dim_names}
56
+
57
+ # check that the supplied interval edges are bounded on the left by the sampled minimum
58
+ for dim in dim_names:
59
+ if np.min(interval_edges[dim]) < specific_mass_intervals.index.get_level_values(dim).left.min():
60
+ raise ValueError(f"The supplied {dim} grid contains values lower than the minimum in the sample.")
61
+
62
+
63
+ # Convert to a numpy 3d array
64
+ # [i, j, k] where i is the dim1, j is the dim2, and k is the component
65
+
66
+ # We will accumulate along both dimensions, but notably we cannot assume that the 2d data sits on a
67
+ # rectilinear grid.
68
+
69
+ # TODO: test this code block in the case of non-rectilinear input data.
70
+ mass_data: np.ndarray = specific_mass_intervals.to_numpy().reshape(
71
+ specific_mass_intervals.index.get_level_values(dim_names[0]).unique().size,
72
+ specific_mass_intervals.index.get_level_values(dim_names[1]).unique().size, -1)
73
+
74
+ mass_data_cum: np.ndarray = np.cumsum(np.cumsum(mass_data, axis=0), axis=1)
75
+
76
+ # create a 3D array to store the upsampled dim2 data (for each original dim1 value)
77
+ upsampled_dim2: np.ndarray = np.zeros(
78
+ (len(original_dim1_vals), len(interval_edges[dim_names[1]]), mass_data_cum.shape[2]))
79
+
80
+ # create a 3D array to store the full upsampled (dim1 and dim2) data
81
+ mass_data_upsampled = np.zeros(
82
+ (len(interval_edges[dim_names[0]]), len(interval_edges[dim_names[1]]), mass_data_cum.shape[2]))
83
+
84
+ for k in range(mass_data_cum.shape[2]): # for every component (c)
85
+
86
+ for i in range(mass_data_cum.shape[0]): # for every dim1 (i), upsample dim2 (j)
87
+ x = original_dim2_vals # dim2
88
+ y = mass_data_cum[i, :, k] # component
89
+
90
+ dim2_spline = PchipInterpolator(x, y, extrapolate=True)
91
+ # now we can upsample the spline to a finer grid
92
+ y_new = dim2_spline(interval_edges[dim_names[1]]) # upsampled dim2
93
+ # Store the upsampled data
94
+ upsampled_dim2[i, :, k] = y_new
95
+
96
+ # for every upsampled dim2 value (j), upsample dim1 (i)
97
+ for j in range(len(interval_edges[dim_names[1]])):
98
+ x = original_dim1_vals # dim1
99
+ y = upsampled_dim2[:, j, k] # component
100
+
101
+ dim1_spline = PchipInterpolator(x, y, extrapolate=True)
102
+ # now we can upsample the spline to a finer grid
103
+ y_new = dim1_spline(interval_edges[dim_names[0]]) # upsampled size
104
+ # Store the upsampled data
105
+ mass_data_upsampled[:, j, k] = y_new
106
+
107
+ print(mass_data_upsampled.shape)
108
+ # Now we can decumulate along both dimensions
109
+ mass_data_decum: np.ndarray = np.diff(
110
+ np.diff(mass_data_upsampled, axis=0,
111
+ prepend=np.zeros((1, mass_data_upsampled.shape[1], mass_data_upsampled.shape[2]))),
112
+ axis=1, prepend=np.zeros((mass_data_upsampled.shape[0], 1, mass_data_upsampled.shape[2])))
113
+
114
+ # Reshape to a 2D array
115
+ mass_data_decum = mass_data_decum.reshape(mass_data_upsampled.shape[0] * mass_data_upsampled.shape[1],
116
+ mass_data_upsampled.shape[2])
117
+
118
+ # Create the multiindex of IntervalIndex for the upsampled data, using the supplied interval edges
119
+
120
+ # Generate the MultiIndex
121
+ min_left_edges = np.array([i.left for i in specific_mass_intervals.index[0]])
122
+ dim1_indexes = pd.IntervalIndex.from_breaks(
123
+ np.round(np.hstack((min_left_edges[0], interval_edges[dim_names[0]])), precision),
124
+ closed='left')
125
+ dim2_indexes = pd.IntervalIndex.from_breaks(
126
+ np.round(np.hstack((min_left_edges[1], interval_edges[dim_names[1]])), precision),
127
+ closed='left')
128
+
129
+ index = pd.MultiIndex.from_product([dim1_indexes, dim2_indexes], names=dim_names)
130
+
131
+ # TODO: account for uniform density for some sizes, like cyclosize.
132
+ # This can be by 3 splines, one for mid, and the two boundaries, by extrapolation.
133
+
134
+ return pd.DataFrame(mass_data_decum, index=index, columns=specific_mass_intervals.columns)
@@ -1,72 +1,72 @@
1
- from typing import Dict
2
-
3
- import networkx as nx
4
- import numpy as np
5
- from networkx import DiGraph, multipartite_layout
6
-
7
-
8
- def digraph_linear_layout(g, orientation: str = "vertical", scale: float = -1.0):
9
- """Position nodes of a digraph in layers of straight lines.
10
-
11
- Parameters
12
- ----------
13
- g : NetworkX graph or list of nodes
14
- A position will be assigned to every node in G.
15
-
16
- orientation : string (default='vertical')
17
-
18
- scale : number (default: 1)
19
- Scale factor for positions.
20
-
21
-
22
- Returns
23
- -------
24
- pos : dict
25
- A dictionary of positions keyed by node.
26
-
27
- Examples
28
- --------
29
- >>> G = nx.complete_multipartite_graph(28, 16, 10)
30
- >>> pos = digraph_linear_layout(g)
31
-
32
- Notes
33
- -----
34
- Intended for use with DiGraphs with a single degree 1 node with an out-edge
35
-
36
- This algorithm currently only works in two dimensions and does not
37
- try to minimize edge crossings.
38
-
39
- """
40
-
41
- src_nodes = [n for n, d in g.in_degree() if d == 0]
42
- g.nodes[src_nodes[0]]['_dist'] = 0
43
- for x_dist in range(1, len(g.nodes) + 1):
44
- nodes_at_x_dist: dict = nx.descendants_at_distance(g, src_nodes[0], x_dist)
45
- if not nodes_at_x_dist:
46
- break
47
- else:
48
- for node in nodes_at_x_dist:
49
- g.nodes[node]['_dist'] = x_dist
50
-
51
- # Ensure all nodes have a _dist attribute
52
- for node in g.nodes:
53
- if '_dist' not in g.nodes[node]:
54
- try:
55
- g.nodes[node]['_dist'] = nx.shortest_path_length(g, source=src_nodes[0], target=node)
56
- except nx.NetworkXNoPath:
57
- g.nodes[node]['_dist'] = 0.0 # or any other default distance
58
-
59
- if orientation == 'vertical':
60
- orientation = 'horizontal'
61
- elif orientation == 'horizontal':
62
- orientation = 'vertical'
63
- scale = -scale
64
- else:
65
- raise ValueError("orientation argument not in 'vertical'|'horizontal'")
66
-
67
- pos = multipartite_layout(g, subset_key="_dist", align=orientation, scale=scale)
68
-
69
- for node in g.nodes:
70
- g.nodes[node].pop('_dist')
71
-
72
- return pos
1
+ from typing import Dict
2
+
3
+ import networkx as nx
4
+ import numpy as np
5
+ from networkx import DiGraph, multipartite_layout
6
+
7
+
8
+ def digraph_linear_layout(g, orientation: str = "vertical", scale: float = -1.0):
9
+ """Position nodes of a digraph in layers of straight lines.
10
+
11
+ Parameters
12
+ ----------
13
+ g : NetworkX graph or list of nodes
14
+ A position will be assigned to every node in G.
15
+
16
+ orientation : string (default='vertical')
17
+
18
+ scale : number (default: 1)
19
+ Scale factor for positions.
20
+
21
+
22
+ Returns
23
+ -------
24
+ pos : dict
25
+ A dictionary of positions keyed by node.
26
+
27
+ Examples
28
+ --------
29
+ >>> G = nx.complete_multipartite_graph(28, 16, 10)
30
+ >>> pos = digraph_linear_layout(g)
31
+
32
+ Notes
33
+ -----
34
+ Intended for use with DiGraphs with a single degree 1 node with an out-edge
35
+
36
+ This algorithm currently only works in two dimensions and does not
37
+ try to minimize edge crossings.
38
+
39
+ """
40
+
41
+ src_nodes = [n for n, d in g.in_degree() if d == 0]
42
+ g.nodes[src_nodes[0]]['_dist'] = 0
43
+ for x_dist in range(1, len(g.nodes) + 1):
44
+ nodes_at_x_dist: dict = nx.descendants_at_distance(g, src_nodes[0], x_dist)
45
+ if not nodes_at_x_dist:
46
+ break
47
+ else:
48
+ for node in nodes_at_x_dist:
49
+ g.nodes[node]['_dist'] = x_dist
50
+
51
+ # Ensure all nodes have a _dist attribute
52
+ for node in g.nodes:
53
+ if '_dist' not in g.nodes[node]:
54
+ try:
55
+ g.nodes[node]['_dist'] = nx.shortest_path_length(g, source=src_nodes[0], target=node)
56
+ except nx.NetworkXNoPath:
57
+ g.nodes[node]['_dist'] = 0.0 # or any other default distance
58
+
59
+ if orientation == 'vertical':
60
+ orientation = 'horizontal'
61
+ elif orientation == 'horizontal':
62
+ orientation = 'vertical'
63
+ scale = -scale
64
+ else:
65
+ raise ValueError("orientation argument not in 'vertical'|'horizontal'")
66
+
67
+ pos = multipartite_layout(g, subset_key="_dist", align=orientation, scale=scale)
68
+
69
+ for node in g.nodes:
70
+ g.nodes[node].pop('_dist')
71
+
72
+ return pos
@@ -1,62 +1,62 @@
1
- import logging
2
- import re
3
- from copy import deepcopy
4
- from typing import Optional, Dict, List
5
-
6
- import numpy as np
7
- import pandas as pd
8
-
9
-
10
- def detect_moisture_column(columns: List[str]) -> Optional[str]:
11
- """Detects the moisture column in a list of columns
12
-
13
- Args:
14
- columns: List of column names
15
-
16
- Returns:
17
-
18
- """
19
- res: Optional[str] = None
20
- search_regex: str = '(h2o)|(moisture)|(moist)|(mc)|(moisture_content)'
21
- for col in columns:
22
- if re.search(search_regex, col, re.IGNORECASE):
23
- res = col
24
- break
25
- return res
26
-
27
-
28
- def solve_mass_moisture(mass_wet: pd.Series = None,
29
- mass_dry: pd.Series = None,
30
- moisture: pd.Series = None,
31
- moisture_column_name: str = 'h2o',
32
- rtol: float = 1e-05,
33
- atol: float = 1e-08) -> pd.Series:
34
- logger = logging.getLogger(name=__name__)
35
- _vars: Dict = {k: v for k, v in deepcopy(locals()).items()}
36
- key_columns = ['mass_wet', 'mass_dry', 'moisture']
37
- vars_supplied: List[str] = [k for k in key_columns if _vars.get(k) is not None]
38
-
39
- if len(vars_supplied) == 3:
40
- logger.info('Over-specified - checking for balance.')
41
- re_calc_moisture = (mass_wet - mass_dry) / mass_wet * 100
42
- if not np.isclose(re_calc_moisture, moisture, rtol=rtol, atol=atol).all():
43
- msg = f"Mass balance is not satisfied: {re_calc_moisture}"
44
- logger.error(msg)
45
- raise ValueError(msg)
46
- elif len(vars_supplied) == 1:
47
- raise ValueError('Insufficient arguments supplied - at least 2 required.')
48
-
49
- var_to_solve: str = next((k for k, v in _vars.items() if v is None), None)
50
-
51
- res: Optional[pd.Series] = None
52
- if var_to_solve:
53
- calculations = {
54
- 'mass_wet': lambda: mass_dry / (1 - moisture / 100),
55
- 'mass_dry': lambda: mass_wet - (mass_wet * moisture / 100),
56
- 'moisture': lambda: (mass_wet - mass_dry) / mass_wet * 100
57
- }
58
-
59
- res = calculations[var_to_solve]()
60
- res.name = var_to_solve if var_to_solve != 'moisture' else moisture_column_name # use the supplied column name
61
-
1
+ import logging
2
+ import re
3
+ from copy import deepcopy
4
+ from typing import Optional, Dict, List
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ def detect_moisture_column(columns: List[str]) -> Optional[str]:
11
+ """Detects the moisture column in a list of columns
12
+
13
+ Args:
14
+ columns: List of column names
15
+
16
+ Returns:
17
+
18
+ """
19
+ res: Optional[str] = None
20
+ search_regex: str = '(h2o)|(moisture)|(moist)|(mc)|(moisture_content)'
21
+ for col in columns:
22
+ if re.search(search_regex, col, re.IGNORECASE):
23
+ res = col
24
+ break
25
+ return res
26
+
27
+
28
+ def solve_mass_moisture(mass_wet: pd.Series = None,
29
+ mass_dry: pd.Series = None,
30
+ moisture: pd.Series = None,
31
+ moisture_column_name: str = 'h2o',
32
+ rtol: float = 1e-05,
33
+ atol: float = 1e-08) -> pd.Series:
34
+ logger = logging.getLogger(name=__name__)
35
+ _vars: Dict = {k: v for k, v in deepcopy(locals()).items()}
36
+ key_columns = ['mass_wet', 'mass_dry', 'moisture']
37
+ vars_supplied: List[str] = [k for k in key_columns if _vars.get(k) is not None]
38
+
39
+ if len(vars_supplied) == 3:
40
+ logger.info('Over-specified - checking for balance.')
41
+ re_calc_moisture = (mass_wet - mass_dry) / mass_wet * 100
42
+ if not np.isclose(re_calc_moisture, moisture, rtol=rtol, atol=atol).all():
43
+ msg = f"Mass balance is not satisfied: {re_calc_moisture}"
44
+ logger.error(msg)
45
+ raise ValueError(msg)
46
+ elif len(vars_supplied) == 1:
47
+ raise ValueError('Insufficient arguments supplied - at least 2 required.')
48
+
49
+ var_to_solve: str = next((k for k, v in _vars.items() if v is None), None)
50
+
51
+ res: Optional[pd.Series] = None
52
+ if var_to_solve:
53
+ calculations = {
54
+ 'mass_wet': lambda: mass_dry / (1 - moisture / 100),
55
+ 'mass_dry': lambda: mass_wet - (mass_wet * moisture / 100),
56
+ 'moisture': lambda: (mass_wet - mass_dry) / mass_wet * 100
57
+ }
58
+
59
+ res = calculations[var_to_solve]()
60
+ res.name = var_to_solve if var_to_solve != 'moisture' else moisture_column_name # use the supplied column name
61
+
62
62
  return res