soil-aggregation-tool 0.1.0__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.
- soil_aggregation_tool/__init__.py +12 -0
- soil_aggregation_tool/__main__.py +6 -0
- soil_aggregation_tool/cli.py +112 -0
- soil_aggregation_tool/core.py +396 -0
- soil_aggregation_tool-0.1.0.dist-info/METADATA +368 -0
- soil_aggregation_tool-0.1.0.dist-info/RECORD +10 -0
- soil_aggregation_tool-0.1.0.dist-info/WHEEL +5 -0
- soil_aggregation_tool-0.1.0.dist-info/entry_points.txt +2 -0
- soil_aggregation_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- soil_aggregation_tool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Aggregate SWAT soil classes and reclassify matching soil rasters."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .core import AggregationResult, aggregate_soils
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("soil-aggregation-tool")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["AggregationResult", "aggregate_soils"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Command-line interface for the soil aggregation tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .core import aggregate_soils
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Aggregate a SWAT usersoil CSV and reclassify its soil raster."
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--usersoil",
|
|
19
|
+
help="Path to the input SWAT usersoil CSV.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--raster",
|
|
23
|
+
help="Path to the input soil GeoTIFF whose values match MUID.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--output-dir",
|
|
27
|
+
help="Directory for the aggregated usersoil, raster, and lookup table.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--max-optimal-clusters",
|
|
31
|
+
type=int,
|
|
32
|
+
default=15,
|
|
33
|
+
help="Maximum k tested per stratum (default: 15).",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--seed",
|
|
37
|
+
type=int,
|
|
38
|
+
default=123,
|
|
39
|
+
help="Random seed for k-means (default: 123).",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--ignore-raster-values",
|
|
43
|
+
type=int,
|
|
44
|
+
nargs="*",
|
|
45
|
+
default=[0, 65535],
|
|
46
|
+
metavar="VALUE",
|
|
47
|
+
help="Raster values treated as background/no-data (default: 0 65535).",
|
|
48
|
+
)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def prompt_for_path(label: str, *, must_exist: bool) -> Path:
|
|
53
|
+
"""Prompt for a path without displaying a bracketed default value."""
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
entered_path = input(f"{label}: ").strip().strip('"')
|
|
57
|
+
if not entered_path:
|
|
58
|
+
print("A path is required.")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
path = Path(entered_path)
|
|
62
|
+
if must_exist and not path.is_file():
|
|
63
|
+
print(f"File not found: {path}")
|
|
64
|
+
continue
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
69
|
+
parser = build_parser()
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
if not sys.stdin.isatty() and not (
|
|
72
|
+
args.usersoil and args.raster and args.output_dir
|
|
73
|
+
):
|
|
74
|
+
parser.error(
|
|
75
|
+
"--usersoil, --raster and --output-dir are required when "
|
|
76
|
+
"input is not interactive."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
usersoil_path = args.usersoil or prompt_for_path(
|
|
80
|
+
"Usersoil CSV path",
|
|
81
|
+
must_exist=True,
|
|
82
|
+
)
|
|
83
|
+
raster_path = args.raster or prompt_for_path(
|
|
84
|
+
"Soil raster path",
|
|
85
|
+
must_exist=True,
|
|
86
|
+
)
|
|
87
|
+
output_dir = args.output_dir or prompt_for_path(
|
|
88
|
+
"Output folder path",
|
|
89
|
+
must_exist=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = aggregate_soils(
|
|
93
|
+
usersoil_path=usersoil_path,
|
|
94
|
+
raster_path=raster_path,
|
|
95
|
+
output_dir=output_dir,
|
|
96
|
+
max_optimal_clusters=args.max_optimal_clusters,
|
|
97
|
+
seed=args.seed,
|
|
98
|
+
ignore_raster_values=args.ignore_raster_values,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if result.missing_raster_muids:
|
|
102
|
+
print(
|
|
103
|
+
f"{len(result.missing_raster_muids)} raster MUID values were not found "
|
|
104
|
+
"in the usersoil CSV; those cells were written as 0."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print(f"Raster unique soil IDs: {result.raster_muid_count}")
|
|
108
|
+
print(f"Matched input soils: {result.matched_soil_count}")
|
|
109
|
+
print(f"Aggregated soil count: {result.aggregated_soil_count}")
|
|
110
|
+
print(f"Usersoil: {result.usersoil_path}")
|
|
111
|
+
print(f"Raster: {result.raster_path}")
|
|
112
|
+
print(f"Lookup: {result.lookup_path}")
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Core soil-reduction workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Silences a joblib warning and traceback on Windows machines where the number
|
|
12
|
+
# of physical CPU cores cannot be detected. A user-set value is kept.
|
|
13
|
+
os.environ.setdefault("LOKY_MAX_CPU_COUNT", "4")
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import rasterio
|
|
18
|
+
from sklearn.cluster import KMeans
|
|
19
|
+
from sklearn.metrics import silhouette_score
|
|
20
|
+
from sklearn.preprocessing import StandardScaler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CLUSTER_COLS = ["SOL_K1", "SOL_AWC1", "SOL_BD1", "USLE_K1"]
|
|
24
|
+
STRATA_COLS = ["HYDGRP", "TEXTURE", "SOL_ZMX", "NLAYERS"]
|
|
25
|
+
REQUIRED_COLS = {"MUID", "SNAM", *CLUSTER_COLS, *STRATA_COLS}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AggregationResult:
|
|
30
|
+
"""Paths and summary counts produced by an aggregation run."""
|
|
31
|
+
|
|
32
|
+
usersoil_path: Path
|
|
33
|
+
raster_path: Path
|
|
34
|
+
lookup_path: Path
|
|
35
|
+
raster_muid_count: int
|
|
36
|
+
matched_soil_count: int
|
|
37
|
+
aggregated_soil_count: int
|
|
38
|
+
missing_raster_muids: tuple[int, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _unlocked_path(path: Path) -> Path:
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with path.open("a"):
|
|
47
|
+
return path
|
|
48
|
+
except PermissionError:
|
|
49
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
new_path = path.with_name(f"{path.stem}_{stamp}{path.suffix}")
|
|
51
|
+
warnings.warn(
|
|
52
|
+
f"{path} is locked by another program; writing {new_path.name} instead.",
|
|
53
|
+
stacklevel=2,
|
|
54
|
+
)
|
|
55
|
+
return new_path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_csv(df: pd.DataFrame, path: Path) -> Path:
|
|
59
|
+
output_path = _unlocked_path(path)
|
|
60
|
+
df.to_csv(output_path, index=False)
|
|
61
|
+
return output_path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_raster_area_by_muid(
|
|
65
|
+
raster_path: Path,
|
|
66
|
+
ignore_values: tuple[int, ...],
|
|
67
|
+
) -> pd.DataFrame:
|
|
68
|
+
with rasterio.open(raster_path) as src:
|
|
69
|
+
if src.count != 1:
|
|
70
|
+
raise ValueError("The input soil raster must contain exactly one band.")
|
|
71
|
+
|
|
72
|
+
values = src.read(1, masked=True).compressed()
|
|
73
|
+
if np.issubdtype(values.dtype, np.floating):
|
|
74
|
+
values = values[~np.isnan(values)]
|
|
75
|
+
|
|
76
|
+
values = values.astype(np.int64)
|
|
77
|
+
if ignore_values:
|
|
78
|
+
values = values[
|
|
79
|
+
~np.isin(values, np.asarray(ignore_values, dtype=np.int64))
|
|
80
|
+
]
|
|
81
|
+
if values.size == 0:
|
|
82
|
+
raise ValueError("The soil raster contains no usable MUID values.")
|
|
83
|
+
|
|
84
|
+
unique_values, cell_counts = np.unique(values, return_counts=True)
|
|
85
|
+
pixel_area = abs(src.transform.a * src.transform.e)
|
|
86
|
+
|
|
87
|
+
return pd.DataFrame(
|
|
88
|
+
{
|
|
89
|
+
"MUID": unique_values.astype(np.int64),
|
|
90
|
+
"raster_cell_count": cell_counts.astype(np.int64),
|
|
91
|
+
"raster_area": cell_counts.astype(float) * pixel_area,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fix_layer_count(df: pd.DataFrame) -> pd.DataFrame:
|
|
97
|
+
layer_depth_cols = [f"SOL_Z{i}" for i in range(1, 11)]
|
|
98
|
+
if not all(col in df.columns for col in layer_depth_cols):
|
|
99
|
+
return df
|
|
100
|
+
|
|
101
|
+
positive_layer_count = (
|
|
102
|
+
df[layer_depth_cols].apply(pd.to_numeric, errors="coerce") > 0
|
|
103
|
+
).sum(axis=1)
|
|
104
|
+
current_layer_count = pd.to_numeric(df["NLAYERS"], errors="coerce")
|
|
105
|
+
bad = current_layer_count != positive_layer_count
|
|
106
|
+
if bad.any():
|
|
107
|
+
df.loc[bad, "NLAYERS"] = positive_layer_count.loc[bad].astype(int)
|
|
108
|
+
return df
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_cluster_keys(df: pd.DataFrame) -> pd.DataFrame:
|
|
112
|
+
features = df[CLUSTER_COLS].apply(pd.to_numeric, errors="coerce")
|
|
113
|
+
if features.isna().any().any():
|
|
114
|
+
bad_cols = features.columns[features.isna().any()].tolist()
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Cluster columns contain non-numeric or missing values: "
|
|
117
|
+
f"{bad_cols}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
features["SOL_K1"] = np.log10(
|
|
121
|
+
np.maximum(features["SOL_K1"].to_numpy(), 0.001)
|
|
122
|
+
)
|
|
123
|
+
return features
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _choose_clusters(
|
|
127
|
+
x_scaled: np.ndarray,
|
|
128
|
+
max_optimal_clusters: int,
|
|
129
|
+
seed: int,
|
|
130
|
+
) -> np.ndarray:
|
|
131
|
+
"""Return 1-based cluster labels for the k with the best silhouette score."""
|
|
132
|
+
|
|
133
|
+
sample_count = x_scaled.shape[0]
|
|
134
|
+
unique_point_count = np.unique(x_scaled, axis=0).shape[0]
|
|
135
|
+
maximum_k = min(
|
|
136
|
+
max_optimal_clusters,
|
|
137
|
+
sample_count - 1,
|
|
138
|
+
unique_point_count,
|
|
139
|
+
)
|
|
140
|
+
best_labels = np.ones(sample_count, dtype=int)
|
|
141
|
+
if maximum_k < 2:
|
|
142
|
+
return best_labels
|
|
143
|
+
|
|
144
|
+
best_score = -np.inf
|
|
145
|
+
for cluster_count in range(2, maximum_k + 1):
|
|
146
|
+
model = KMeans(
|
|
147
|
+
n_clusters=cluster_count,
|
|
148
|
+
n_init=20,
|
|
149
|
+
max_iter=100,
|
|
150
|
+
random_state=seed,
|
|
151
|
+
)
|
|
152
|
+
labels = model.fit_predict(x_scaled)
|
|
153
|
+
if len(np.unique(labels)) < 2:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
score = silhouette_score(x_scaled, labels)
|
|
157
|
+
if score > best_score:
|
|
158
|
+
best_labels = labels + 1
|
|
159
|
+
best_score = score
|
|
160
|
+
|
|
161
|
+
return best_labels
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cluster_study_soils(
|
|
165
|
+
study_soils: pd.DataFrame,
|
|
166
|
+
cluster_keys: pd.DataFrame,
|
|
167
|
+
max_optimal_clusters: int,
|
|
168
|
+
seed: int,
|
|
169
|
+
) -> pd.DataFrame:
|
|
170
|
+
clustered = study_soils.copy()
|
|
171
|
+
clustered["_stratum"] = clustered[STRATA_COLS].astype(str).agg(
|
|
172
|
+
"__".join,
|
|
173
|
+
axis=1,
|
|
174
|
+
)
|
|
175
|
+
clustered["_cluster_global"] = np.nan
|
|
176
|
+
next_cluster_id = 1
|
|
177
|
+
|
|
178
|
+
for indices in clustered.groupby("_stratum", sort=True).groups.values():
|
|
179
|
+
indices = list(indices)
|
|
180
|
+
features = cluster_keys.loc[indices].copy()
|
|
181
|
+
variable_columns = features.std(axis=0, ddof=0) > 0
|
|
182
|
+
|
|
183
|
+
if len(indices) < 3 or not variable_columns.any():
|
|
184
|
+
local_clusters = np.ones(len(indices), dtype=int)
|
|
185
|
+
else:
|
|
186
|
+
scaled = StandardScaler().fit_transform(
|
|
187
|
+
features.loc[:, variable_columns]
|
|
188
|
+
)
|
|
189
|
+
local_clusters = _choose_clusters(
|
|
190
|
+
scaled,
|
|
191
|
+
max_optimal_clusters=max_optimal_clusters,
|
|
192
|
+
seed=seed,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
local_to_global: dict[int, int] = {}
|
|
196
|
+
for local_id in sorted(np.unique(local_clusters)):
|
|
197
|
+
local_to_global[int(local_id)] = next_cluster_id
|
|
198
|
+
next_cluster_id += 1
|
|
199
|
+
|
|
200
|
+
clustered.loc[indices, "_cluster_global"] = [
|
|
201
|
+
local_to_global[int(local_id)] for local_id in local_clusters
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
clustered["_cluster_global"] = clustered["_cluster_global"].astype(int)
|
|
205
|
+
return clustered
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _weighted_mode(values: pd.Series, weights: pd.Series):
|
|
209
|
+
weighted_counts = weights.groupby(values).sum().sort_values(ascending=False)
|
|
210
|
+
if weighted_counts.empty:
|
|
211
|
+
return values.iloc[0]
|
|
212
|
+
return weighted_counts.index[0]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _aggregate_cluster(
|
|
216
|
+
group: pd.DataFrame,
|
|
217
|
+
new_muid: int,
|
|
218
|
+
original_columns: list[str],
|
|
219
|
+
) -> pd.Series:
|
|
220
|
+
output = group.iloc[0][original_columns].copy()
|
|
221
|
+
weights = group["raster_area"].astype(float)
|
|
222
|
+
if weights.sum() <= 0:
|
|
223
|
+
weights = group["raster_cell_count"].astype(float)
|
|
224
|
+
|
|
225
|
+
for column in original_columns:
|
|
226
|
+
if pd.api.types.is_numeric_dtype(group[column]):
|
|
227
|
+
output[column] = group[column].mean()
|
|
228
|
+
else:
|
|
229
|
+
output[column] = _weighted_mode(group[column], weights)
|
|
230
|
+
|
|
231
|
+
output["MUID"] = new_muid
|
|
232
|
+
if "SEQN" in output.index:
|
|
233
|
+
output["SEQN"] = new_muid
|
|
234
|
+
if "OBJECTID" in output.index:
|
|
235
|
+
output["OBJECTID"] = new_muid
|
|
236
|
+
output["SNAM"] = f"Soil_{new_muid}"
|
|
237
|
+
if "S5ID" in output.index:
|
|
238
|
+
output["S5ID"] = 0
|
|
239
|
+
if "CMPPCT" in output.index:
|
|
240
|
+
output["CMPPCT"] = 100
|
|
241
|
+
return output
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _make_reduced_and_mapping(
|
|
245
|
+
study_soils: pd.DataFrame,
|
|
246
|
+
original_columns: list[str],
|
|
247
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
248
|
+
reduced_rows: list[pd.Series] = []
|
|
249
|
+
mapping_rows: list[pd.DataFrame] = []
|
|
250
|
+
|
|
251
|
+
groups = study_soils.groupby("_cluster_global", sort=True)
|
|
252
|
+
for new_muid, (_, group) in enumerate(groups, start=1):
|
|
253
|
+
reduced_rows.append(
|
|
254
|
+
_aggregate_cluster(group, new_muid, original_columns)
|
|
255
|
+
)
|
|
256
|
+
mapping_rows.append(
|
|
257
|
+
pd.DataFrame(
|
|
258
|
+
{
|
|
259
|
+
"old_muid": group["MUID"].to_numpy(),
|
|
260
|
+
"new_muid": new_muid,
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
reduced = pd.DataFrame(reduced_rows, columns=original_columns)
|
|
266
|
+
mapping = pd.concat(mapping_rows, ignore_index=True)
|
|
267
|
+
return reduced, mapping
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _reclassify_raster(
|
|
271
|
+
input_raster: Path,
|
|
272
|
+
output_raster: Path,
|
|
273
|
+
mapping: pd.DataFrame,
|
|
274
|
+
ignore_values: tuple[int, ...],
|
|
275
|
+
) -> Path:
|
|
276
|
+
order = np.argsort(mapping["old_muid"].to_numpy(dtype=np.int64))
|
|
277
|
+
old_muids = mapping["old_muid"].to_numpy(dtype=np.int64)[order]
|
|
278
|
+
new_muids = mapping["new_muid"].to_numpy(dtype=np.int32)[order]
|
|
279
|
+
|
|
280
|
+
with rasterio.open(input_raster) as src:
|
|
281
|
+
profile = src.profile.copy()
|
|
282
|
+
input_values = src.read(1)
|
|
283
|
+
output_values = np.zeros(input_values.shape, dtype=np.int32)
|
|
284
|
+
|
|
285
|
+
valid_mask = np.ones(input_values.shape, dtype=bool)
|
|
286
|
+
if np.issubdtype(input_values.dtype, np.floating):
|
|
287
|
+
valid_mask &= ~np.isnan(input_values)
|
|
288
|
+
if src.nodata is not None:
|
|
289
|
+
valid_mask &= input_values != src.nodata
|
|
290
|
+
if ignore_values:
|
|
291
|
+
valid_mask &= ~np.isin(
|
|
292
|
+
input_values.astype(np.int64),
|
|
293
|
+
np.asarray(ignore_values, dtype=np.int64),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Vectorized lookup: MUIDs absent from the mapping are written as 0.
|
|
297
|
+
old_values = input_values[valid_mask].astype(np.int64)
|
|
298
|
+
positions = np.searchsorted(old_muids, old_values)
|
|
299
|
+
positions = np.clip(positions, 0, old_muids.size - 1)
|
|
300
|
+
found = old_muids[positions] == old_values
|
|
301
|
+
output_values[valid_mask] = np.where(found, new_muids[positions], 0)
|
|
302
|
+
profile.update(dtype=rasterio.int32, nodata=0, compress="lzw")
|
|
303
|
+
|
|
304
|
+
actual_output_path = _unlocked_path(output_raster)
|
|
305
|
+
with rasterio.open(actual_output_path, "w", **profile) as dst:
|
|
306
|
+
dst.write(output_values, 1)
|
|
307
|
+
|
|
308
|
+
return actual_output_path
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def aggregate_soils(
|
|
312
|
+
usersoil_path: str | Path,
|
|
313
|
+
raster_path: str | Path,
|
|
314
|
+
output_dir: str | Path,
|
|
315
|
+
*,
|
|
316
|
+
max_optimal_clusters: int = 15,
|
|
317
|
+
seed: int = 123,
|
|
318
|
+
ignore_raster_values: list[int] | tuple[int, ...] = (0, 65535),
|
|
319
|
+
) -> AggregationResult:
|
|
320
|
+
"""Aggregate a usersoil table and reclassify its matching soil raster."""
|
|
321
|
+
|
|
322
|
+
usersoil_path = Path(usersoil_path)
|
|
323
|
+
raster_path = Path(raster_path)
|
|
324
|
+
output_dir = Path(output_dir)
|
|
325
|
+
|
|
326
|
+
if not usersoil_path.is_file():
|
|
327
|
+
raise FileNotFoundError(f"Usersoil CSV not found: {usersoil_path}")
|
|
328
|
+
if not raster_path.is_file():
|
|
329
|
+
raise FileNotFoundError(f"Soil raster not found: {raster_path}")
|
|
330
|
+
if max_optimal_clusters < 1:
|
|
331
|
+
raise ValueError("max_optimal_clusters must be at least 1.")
|
|
332
|
+
|
|
333
|
+
ignored_values = tuple(int(value) for value in ignore_raster_values)
|
|
334
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
|
|
336
|
+
usersoil = pd.read_csv(usersoil_path)
|
|
337
|
+
missing_columns = sorted(REQUIRED_COLS.difference(usersoil.columns))
|
|
338
|
+
if missing_columns:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"Usersoil CSV is missing required columns: {missing_columns}"
|
|
341
|
+
)
|
|
342
|
+
original_columns = usersoil.columns.tolist()
|
|
343
|
+
|
|
344
|
+
numeric_muids = pd.to_numeric(usersoil["MUID"], errors="coerce")
|
|
345
|
+
if numeric_muids.isna().any():
|
|
346
|
+
raise ValueError("The usersoil MUID column must contain only integers.")
|
|
347
|
+
usersoil["MUID"] = numeric_muids.astype(np.int64)
|
|
348
|
+
|
|
349
|
+
raster_area = _read_raster_area_by_muid(raster_path, ignored_values)
|
|
350
|
+
raster_muids = set(raster_area["MUID"].astype(int).tolist())
|
|
351
|
+
usersoil_muids = set(usersoil["MUID"].astype(int).tolist())
|
|
352
|
+
missing_raster_muids = tuple(sorted(raster_muids.difference(usersoil_muids)))
|
|
353
|
+
|
|
354
|
+
study_soils = usersoil[usersoil["MUID"].isin(raster_muids)].copy()
|
|
355
|
+
if study_soils.empty:
|
|
356
|
+
raise ValueError("No raster MUID values matched the usersoil CSV.")
|
|
357
|
+
|
|
358
|
+
study_soils = study_soils.merge(raster_area, on="MUID", how="left")
|
|
359
|
+
if study_soils["raster_cell_count"].isna().any():
|
|
360
|
+
raise ValueError("Some matched MUIDs did not receive raster area weights.")
|
|
361
|
+
|
|
362
|
+
study_soils = _fix_layer_count(study_soils)
|
|
363
|
+
cluster_keys = _build_cluster_keys(study_soils)
|
|
364
|
+
clustered = _cluster_study_soils(
|
|
365
|
+
study_soils,
|
|
366
|
+
cluster_keys,
|
|
367
|
+
max_optimal_clusters=max_optimal_clusters,
|
|
368
|
+
seed=seed,
|
|
369
|
+
)
|
|
370
|
+
reduced, mapping = _make_reduced_and_mapping(clustered, original_columns)
|
|
371
|
+
lookup = reduced[["MUID", "SNAM"]].copy()
|
|
372
|
+
|
|
373
|
+
usersoil_output = _write_csv(
|
|
374
|
+
reduced,
|
|
375
|
+
output_dir / "Soil_usersoil.csv",
|
|
376
|
+
)
|
|
377
|
+
lookup_output = _write_csv(
|
|
378
|
+
lookup,
|
|
379
|
+
output_dir / "Soil_lookup.csv",
|
|
380
|
+
)
|
|
381
|
+
raster_output = _reclassify_raster(
|
|
382
|
+
raster_path,
|
|
383
|
+
output_dir / "Soil.tif",
|
|
384
|
+
mapping,
|
|
385
|
+
ignored_values,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return AggregationResult(
|
|
389
|
+
usersoil_path=usersoil_output.resolve(),
|
|
390
|
+
raster_path=raster_output.resolve(),
|
|
391
|
+
lookup_path=lookup_output.resolve(),
|
|
392
|
+
raster_muid_count=len(raster_muids),
|
|
393
|
+
matched_soil_count=len(study_soils),
|
|
394
|
+
aggregated_soil_count=len(reduced),
|
|
395
|
+
missing_raster_muids=missing_raster_muids,
|
|
396
|
+
)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: soil-aggregation-tool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Aggregate SWAT usersoil classes and reclassify a matching soil raster.
|
|
5
|
+
Author: Yashas Kumar
|
|
6
|
+
Maintainer-email: Chandan Kumar <chandankr014@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/chandankr014/swatplus-soil-aggregation-tool
|
|
9
|
+
Project-URL: Issues, https://github.com/chandankr014/swatplus-soil-aggregation-tool/issues
|
|
10
|
+
Keywords: SWAT,SWAT+,soil,hydrology,raster,clustering
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Hydrology
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.24
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Requires-Dist: rasterio>=1.3
|
|
22
|
+
Requires-Dist: scikit-learn>=1.3
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Soil Aggregation Tool
|
|
26
|
+
|
|
27
|
+
`Soil_aggregation_tool` is an installable Python package for aggregating SWAT
|
|
28
|
+
usersoil classes and reclassifying a matching soil raster. It groups comparable
|
|
29
|
+
soils, selects an appropriate number of k-means clusters, and produces a smaller
|
|
30
|
+
usersoil table, a matching raster, and a two-column lookup table.
|
|
31
|
+
|
|
32
|
+
Developed by Yashas Kumar. Packaged and maintained by
|
|
33
|
+
[chandankr014](https://github.com/chandankr014).
|
|
34
|
+
|
|
35
|
+
## Outputs
|
|
36
|
+
|
|
37
|
+
Each run creates exactly these three files:
|
|
38
|
+
|
|
39
|
+
| Output file | Contents |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `Soil_usersoil.csv` | Aggregated usersoil table with the same columns as the input usersoil CSV. |
|
|
42
|
+
| `Soil_lookup.csv` | Lookup containing only `MUID` and `SNAM`, copied from `Soil_usersoil.csv`. |
|
|
43
|
+
| `Soil.tif` | Input soil raster reclassified to the new aggregated MUID values. |
|
|
44
|
+
|
|
45
|
+
Generated soil names are `Soil_1`, `Soil_2`, `Soil_3`, and so on. Therefore,
|
|
46
|
+
the `MUID` and `SNAM` pairs in `Soil_lookup.csv` always match those in
|
|
47
|
+
`Soil_usersoil.csv`.
|
|
48
|
+
|
|
49
|
+
The program does not delete unrelated files already present in the output
|
|
50
|
+
folder. For a folder containing only these three files, use a new or empty
|
|
51
|
+
output folder.
|
|
52
|
+
|
|
53
|
+
## How the tool works
|
|
54
|
+
|
|
55
|
+
1. It reads the unique MUID values and cell areas from the input soil raster.
|
|
56
|
+
2. It selects usersoil rows whose `MUID` values occur in that raster.
|
|
57
|
+
3. It divides the selected soils into comparable strata using `HYDGRP`,
|
|
58
|
+
`TEXTURE`, `SOL_ZMX`, and `NLAYERS`.
|
|
59
|
+
4. Within each stratum, it clusters soils using `SOL_K1`, `SOL_AWC1`,
|
|
60
|
+
`SOL_BD1`, and `USLE_K1`.
|
|
61
|
+
5. It compares candidate cluster counts using the silhouette score.
|
|
62
|
+
6. It aggregates each selected cluster into one usersoil row and assigns new
|
|
63
|
+
sequential MUIDs beginning with `1`.
|
|
64
|
+
7. It reclassifies the raster from the original MUID values to the new MUIDs.
|
|
65
|
+
8. It copies `MUID` and `SNAM` from the output usersoil into the lookup file.
|
|
66
|
+
|
|
67
|
+
## Input requirements
|
|
68
|
+
|
|
69
|
+
### Usersoil CSV
|
|
70
|
+
|
|
71
|
+
The input CSV must contain these columns:
|
|
72
|
+
|
|
73
|
+
| Column | Use |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `MUID` | Soil identifier corresponding to raster cell values. |
|
|
76
|
+
| `SNAM` | Soil name. Output names are replaced with `Soil_1`, `Soil_2`, etc. |
|
|
77
|
+
| `HYDGRP` | Hydrologic soil group used to define strata. |
|
|
78
|
+
| `TEXTURE` | Soil texture used to define strata. |
|
|
79
|
+
| `SOL_ZMX` | Maximum soil depth used to define strata. |
|
|
80
|
+
| `NLAYERS` | Number of soil layers used to define strata. |
|
|
81
|
+
| `SOL_K1` | Layer-one saturated hydraulic conductivity used for clustering. |
|
|
82
|
+
| `SOL_AWC1` | Layer-one available water capacity used for clustering. |
|
|
83
|
+
| `SOL_BD1` | Layer-one bulk density used for clustering. |
|
|
84
|
+
| `USLE_K1` | Layer-one soil erodibility factor used for clustering. |
|
|
85
|
+
|
|
86
|
+
All other input usersoil columns are retained in `Soil_usersoil.csv`. If the
|
|
87
|
+
input includes every column from `SOL_Z1` through `SOL_Z10`, the tool corrects
|
|
88
|
+
an inconsistent `NLAYERS` value from the number of positive layer depths.
|
|
89
|
+
|
|
90
|
+
### Soil raster
|
|
91
|
+
|
|
92
|
+
The raster must:
|
|
93
|
+
|
|
94
|
+
- be a single-band GeoTIFF;
|
|
95
|
+
- contain cell values corresponding to `MUID` values in the usersoil CSV; and
|
|
96
|
+
- contain valid raster dimensions, transform, and coordinate-system metadata.
|
|
97
|
+
|
|
98
|
+
Values `0` and `65535` are treated as background or no-data by default. Raster
|
|
99
|
+
MUIDs that do not occur in the usersoil CSV are written as `0` in `Soil.tif`
|
|
100
|
+
and reported in the terminal.
|
|
101
|
+
|
|
102
|
+
## Software requirements
|
|
103
|
+
|
|
104
|
+
- Python 3.10 or newer
|
|
105
|
+
- NumPy
|
|
106
|
+
- pandas
|
|
107
|
+
- Rasterio
|
|
108
|
+
- scikit-learn
|
|
109
|
+
|
|
110
|
+
Python installs the package dependencies from `pyproject.toml`.
|
|
111
|
+
|
|
112
|
+
## Download and install from GitHub
|
|
113
|
+
|
|
114
|
+
First, install Git and Python 3.10 or newer. Then follow these steps.
|
|
115
|
+
|
|
116
|
+
### 1. Download the repository
|
|
117
|
+
|
|
118
|
+
Open PowerShell or a terminal and run:
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
git clone https://github.com/chandankr014/swatplus-soil-aggregation-tool.git
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 2. Enter the downloaded folder
|
|
125
|
+
|
|
126
|
+
```powershell
|
|
127
|
+
cd swatplus-soil-aggregation-tool
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 3. Create a virtual environment
|
|
131
|
+
|
|
132
|
+
```powershell
|
|
133
|
+
python -m venv .venv
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Activate it on Windows PowerShell:
|
|
137
|
+
|
|
138
|
+
```powershell
|
|
139
|
+
.\.venv\Scripts\Activate.ps1
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
On macOS or Linux, activate it with:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
source .venv/bin/activate
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 4. Install the package
|
|
149
|
+
|
|
150
|
+
```powershell
|
|
151
|
+
python -m pip install .
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The required Python packages are installed automatically. Confirm that the
|
|
155
|
+
installation succeeded:
|
|
156
|
+
|
|
157
|
+
```powershell
|
|
158
|
+
soil-aggregation-tool --help
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Install directly without cloning
|
|
162
|
+
|
|
163
|
+
The package can also be installed directly from GitHub:
|
|
164
|
+
|
|
165
|
+
```powershell
|
|
166
|
+
python -m pip install "git+https://github.com/chandankr014/swatplus-soil-aggregation-tool.git"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Install in Jupyter Notebook
|
|
170
|
+
|
|
171
|
+
Run this command in a notebook cell to install the package into the environment
|
|
172
|
+
used by the current notebook kernel:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
%pip install "git+https://github.com/chandankr014/swatplus-soil-aggregation-tool.git"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Restart the notebook kernel after installation. The Python API can then be used
|
|
179
|
+
directly in another cell:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from soil_aggregation_tool import aggregate_soils
|
|
183
|
+
|
|
184
|
+
result = aggregate_soils(
|
|
185
|
+
usersoil_path="usersoil.csv",
|
|
186
|
+
raster_path="soil_input.tif",
|
|
187
|
+
output_dir="soil_output",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
print(result.usersoil_path)
|
|
191
|
+
print(result.lookup_path)
|
|
192
|
+
print(result.raster_path)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Running with interactive input
|
|
196
|
+
|
|
197
|
+
Run the command without file arguments:
|
|
198
|
+
|
|
199
|
+
```powershell
|
|
200
|
+
soil-aggregation-tool
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The program asks for the inputs as follows:
|
|
204
|
+
|
|
205
|
+
```text
|
|
206
|
+
Usersoil CSV path:
|
|
207
|
+
Soil raster path:
|
|
208
|
+
Output folder path:
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Enter a complete or relative path after each prompt. The prompts do not use
|
|
212
|
+
default filenames or square brackets.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
|
|
216
|
+
```text
|
|
217
|
+
Usersoil CSV path: D:\data\usersoil.csv
|
|
218
|
+
Soil raster path: D:\data\soil_input.tif
|
|
219
|
+
Output folder path: D:\data\soil_output
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Running with command-line arguments
|
|
223
|
+
|
|
224
|
+
File paths can be supplied directly to avoid interactive prompts:
|
|
225
|
+
|
|
226
|
+
```powershell
|
|
227
|
+
soil-aggregation-tool `
|
|
228
|
+
--usersoil "D:\data\usersoil.csv" `
|
|
229
|
+
--raster "D:\data\soil_input.tif" `
|
|
230
|
+
--output-dir "D:\data\soil_output"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The Python module form is equivalent:
|
|
234
|
+
|
|
235
|
+
```powershell
|
|
236
|
+
python -m soil_aggregation_tool `
|
|
237
|
+
--usersoil "D:\data\usersoil.csv" `
|
|
238
|
+
--raster "D:\data\soil_input.tif" `
|
|
239
|
+
--output-dir "D:\data\soil_output"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Paths are quoted so paths containing spaces work correctly. The output folder
|
|
243
|
+
is created automatically when it does not exist.
|
|
244
|
+
|
|
245
|
+
### Optional settings
|
|
246
|
+
|
|
247
|
+
| Argument | Default | Description |
|
|
248
|
+
| --- | ---: | --- |
|
|
249
|
+
| `--max-optimal-clusters` | `15` | Maximum number of clusters evaluated within each soil stratum. |
|
|
250
|
+
| `--seed` | `123` | Random seed that makes k-means results reproducible. |
|
|
251
|
+
| `--ignore-raster-values` | `0 65535` | Space-separated raster values treated as background. |
|
|
252
|
+
|
|
253
|
+
Example using `0` and `-9999` as ignored values:
|
|
254
|
+
|
|
255
|
+
```powershell
|
|
256
|
+
soil-aggregation-tool `
|
|
257
|
+
--usersoil "D:\data\usersoil.csv" `
|
|
258
|
+
--raster "D:\data\soil_input.tif" `
|
|
259
|
+
--output-dir "D:\data\soil_output" `
|
|
260
|
+
--ignore-raster-values 0 -9999
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Using the package from Python
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from soil_aggregation_tool import aggregate_soils
|
|
267
|
+
|
|
268
|
+
result = aggregate_soils(
|
|
269
|
+
usersoil_path="usersoil.csv",
|
|
270
|
+
raster_path="soil_input.tif",
|
|
271
|
+
output_dir="soil_output",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
print(result.usersoil_path)
|
|
275
|
+
print(result.lookup_path)
|
|
276
|
+
print(result.raster_path)
|
|
277
|
+
print(result.aggregated_soil_count)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Running the tests
|
|
281
|
+
|
|
282
|
+
Run the automated test from the repository folder:
|
|
283
|
+
|
|
284
|
+
```powershell
|
|
285
|
+
python -m unittest discover -s tests -v
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The tests create a small usersoil CSV and GeoTIFF, run the complete workflow,
|
|
289
|
+
and check that:
|
|
290
|
+
|
|
291
|
+
- only the three documented output files are created;
|
|
292
|
+
- `Soil_lookup.csv` has only the `MUID` and `SNAM` columns;
|
|
293
|
+
- lookup values exactly match `Soil_usersoil.csv`;
|
|
294
|
+
- the new MUID values are written into `Soil.tif`;
|
|
295
|
+
- raster MUIDs missing from the usersoil CSV are written as `0`; and
|
|
296
|
+
- a missing required column raises a clear error.
|
|
297
|
+
|
|
298
|
+
The same tests run in GitHub Actions before each release is published.
|
|
299
|
+
|
|
300
|
+
## Publishing a release to PyPI
|
|
301
|
+
|
|
302
|
+
Pushing a tag that starts with `v` runs `.github/workflows/publish.yml`, which
|
|
303
|
+
tests, builds, and uploads the package to PyPI. The package version is taken
|
|
304
|
+
from the tag, so there is no version number to edit by hand.
|
|
305
|
+
|
|
306
|
+
One-time setup: create a PyPI API token and add it on GitHub under
|
|
307
|
+
**Settings → Secrets and variables → Actions** as `PYPI_API_TOKEN`.
|
|
308
|
+
|
|
309
|
+
To release a new version:
|
|
310
|
+
|
|
311
|
+
```powershell
|
|
312
|
+
git add .
|
|
313
|
+
git commit -m "Release v0.1.0"
|
|
314
|
+
git push origin main
|
|
315
|
+
git tag v0.1.0
|
|
316
|
+
git push origin v0.1.0
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Check progress in the repository's **Actions** tab. After it finishes, install
|
|
320
|
+
with:
|
|
321
|
+
|
|
322
|
+
```powershell
|
|
323
|
+
pip install --no-cache-dir soil-aggregation-tool
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## License
|
|
327
|
+
|
|
328
|
+
Released under the MIT License. See [LICENSE](LICENSE).
|
|
329
|
+
|
|
330
|
+
## Repository structure
|
|
331
|
+
|
|
332
|
+
```text
|
|
333
|
+
Soil_aggregation_tool/
|
|
334
|
+
|-- .github/
|
|
335
|
+
| `-- workflows/
|
|
336
|
+
| `-- publish.yml
|
|
337
|
+
|-- .gitignore
|
|
338
|
+
|-- LICENSE
|
|
339
|
+
|-- README.md
|
|
340
|
+
|-- pyproject.toml
|
|
341
|
+
|-- src/
|
|
342
|
+
| `-- soil_aggregation_tool/
|
|
343
|
+
| |-- __init__.py
|
|
344
|
+
| |-- __main__.py
|
|
345
|
+
| |-- cli.py
|
|
346
|
+
| `-- core.py
|
|
347
|
+
`-- tests/
|
|
348
|
+
`-- test_aggregation.py
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Large CSV and TIFF datasets are excluded by `.gitignore`. Keep input data
|
|
352
|
+
outside the repository, or check that it is not staged before committing.
|
|
353
|
+
|
|
354
|
+
## Common errors
|
|
355
|
+
|
|
356
|
+
- **Usersoil CSV not found:** check the path entered at `Usersoil CSV path` or
|
|
357
|
+
supplied to `--usersoil`.
|
|
358
|
+
- **Soil raster not found:** check the path entered at `Soil raster path` or
|
|
359
|
+
supplied to `--raster`.
|
|
360
|
+
- **Missing required columns:** add or rename the columns listed in the usersoil
|
|
361
|
+
input section.
|
|
362
|
+
- **No raster MUID values matched:** confirm that raster cell values and the
|
|
363
|
+
usersoil `MUID` column use the same identifiers.
|
|
364
|
+
- **Output file is open:** close the file in other software and run the tool
|
|
365
|
+
again. When an output is locked, the tool warns and uses a timestamped
|
|
366
|
+
filename rather than overwriting the open file.
|
|
367
|
+
- **Arguments are required when input is not interactive:** when running from
|
|
368
|
+
a script or scheduled job, pass `--usersoil`, `--raster`, and `--output-dir`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
soil_aggregation_tool/__init__.py,sha256=k6dC6QRhOxAsPXdiT7SaN9kUikDuqJujZd8I7tbV734,352
|
|
2
|
+
soil_aggregation_tool/__main__.py,sha256=14FfnaF7zY550dRxcOV5XinMWDf5fRBJZkgTBoqeVj8,63
|
|
3
|
+
soil_aggregation_tool/cli.py,sha256=hsLHBqyjtKhF9L2DP2wcieow7emIIaDC6nBaGEJzG_w,3320
|
|
4
|
+
soil_aggregation_tool/core.py,sha256=4-GUHP9cM66g5Aru7AyOgvX-BLL9Dz8c-w6LaBlVnDk,13059
|
|
5
|
+
soil_aggregation_tool-0.1.0.dist-info/licenses/LICENSE,sha256=H6QFaHy6sKBIVk65VwwNQIupZJ_oUPmYqCDBtS2QYek,1087
|
|
6
|
+
soil_aggregation_tool-0.1.0.dist-info/METADATA,sha256=YDsjhvr_ComjnaGQolBJIkQnjUCqX2Jw4_om2MqEYq0,10793
|
|
7
|
+
soil_aggregation_tool-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
soil_aggregation_tool-0.1.0.dist-info/entry_points.txt,sha256=-E9Es9NNie9FI3Htr8cueNEs99lRdbyJ7RNKrkPGef8,73
|
|
9
|
+
soil_aggregation_tool-0.1.0.dist-info/top_level.txt,sha256=TLiLDFMwe4cWnd2Mn0ZQPT5MZ61LBjlSckXcqN7mZCk,22
|
|
10
|
+
soil_aggregation_tool-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yashas Kumar and Chandan Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
soil_aggregation_tool
|