riftprospect 0.1.0__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.
@@ -0,0 +1,23 @@
1
+ {
2
+ "_comment": "Author block. Every document, citation file, Zenodo record and commit reads from here. ORCID and email copied from MCR-GeoChem's AUTHORS.json; confirm in the verification checklist.",
3
+ "authors": [
4
+ {
5
+ "name": "Bezali Kwame Danso",
6
+ "family": "Danso",
7
+ "given": "Bezali Kwame",
8
+ "orcid": "0009-0006-1201-4129",
9
+ "email": "dansobez@msu.edu",
10
+ "affiliation": "Independent Researcher, East Lansing, Michigan, USA",
11
+ "roles": [
12
+ "Conceptualization",
13
+ "Methodology",
14
+ "Validation",
15
+ "Formal analysis",
16
+ "Writing - original draft"
17
+ ],
18
+ "corresponding": true,
19
+ "github": "bkdanso"
20
+ }
21
+ ],
22
+ "collaborators": []
23
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bezali Kwame Danso
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,14 @@
1
+ # Data license
2
+
3
+ *Plain-language note: the code is MIT (anyone may reuse it with credit). The data products are CC BY-SA 4.0: anyone may reuse them with credit, and must share their own derived versions under the same license. This is required because they are built from MCR-GeoChem, which is CC BY-SA 4.0.*
4
+
5
+ The following are licensed under the Creative Commons Attribution-ShareAlike 4.0 International License (https://creativecommons.org/licenses/by-sa/4.0/):
6
+ `data/processed/` (including `targets_ranked.csv`), `site/data/`, `site/img/`, and `paper/figures/`.
7
+
8
+ Documentation in `docs/` and `paper/` text: CC BY 4.0.
9
+
10
+ Attribution for the underlying sources (please cite them too):
11
+ - USGS, Critical mineral deposits of the United States, ver. 3.0 (Hammarstrom, Woodruff, Dicken and Zach, 2026), doi:10.5066/P9K1HBNT. CC0.
12
+ - USGS, Airborne magnetic and radiometric survey, Upper Peninsula, Michigan, and parts of northern Wisconsin, 2025 (Drenth, Murchek, Brown and Connell, 2026), doi:10.5066/P14ESDF7. Public domain.
13
+ - MCR-GeoChem v1.0.1 (Danso and Abdullah, 2026), doi:10.5281/zenodo.23516434. CC BY-SA 4.0.
14
+ - USGS State Geologic Map Compilation (Horton, San Juan and Stoeser, 2017), doi:10.3133/ds1052. Public domain.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: riftprospect
3
+ Version: 0.1.0
4
+ Summary: Open prospectivity modeling for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift
5
+ Author: Bezali Kwame Danso
6
+ License: MIT
7
+ Project-URL: Source, https://github.com/bkdanso/riftprospect
8
+ Keywords: mineral prospectivity,critical minerals,nickel,platinum-group elements,Midcontinent Rift,geochemistry,aeromagnetics
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Topic :: Scientific/Engineering :: GIS
12
+ Classifier: Intended Audience :: Science/Research
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: LICENSE-DATA.md
17
+ License-File: AUTHORS.json
18
+ Requires-Dist: numpy>=1.24
19
+ Requires-Dist: pandas>=2.0
20
+ Requires-Dist: scipy>=1.10
21
+ Requires-Dist: scikit-learn>=1.3
22
+ Requires-Dist: rasterio>=1.3
23
+ Requires-Dist: geopandas>=0.14
24
+ Requires-Dist: pyproj>=3.5
25
+ Requires-Dist: pyarrow>=12
26
+ Requires-Dist: openpyxl>=3.1
27
+ Requires-Dist: matplotlib>=3.7
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest>=7; extra == "test"
30
+ Dynamic: license-file
31
+
32
+ # riftprospect: open prospectivity model for magmatic Ni-Cu-Co-PGE in the Midcontinent Rift
33
+
34
+ **Status:** Released | v0.1.0 | built 2026-10-27 | **Software DOI:** [10.5281/zenodo.23697827](https://doi.org/10.5281/zenodo.23697827) | **Target table DOI:** [10.5281/zenodo.23697829](https://doi.org/10.5281/zenodo.23697829) | **Map:** [https://bkdanso.github.io/riftprospect/](https://bkdanso.github.io/riftprospect/) | **Maintainer:** Bezali Kwame Danso ([ORCID](https://orcid.org/0009-0006-1201-4129)) | Code MIT, data CC BY-SA 4.0
35
+
36
+ **In plain terms:** Nickel, cobalt, copper and the platinum-group metals are on the 2025 U.S. critical minerals list. The Eagle mine near Marquette, Michigan, is the only U.S. mine that produces nickel as its main product, and the rock around Lake Superior could hold more deposits like it, mostly hidden under glacial sediment. This project turns free public data into a ranked list of places worth checking. It scores every 200 m square of the 2025 USGS airborne survey of the Upper Peninsula and northern Wisconsin (39,368.6 km², 984,215 squares) by how much its magnetism and natural radioactivity look like the small, dense, magnetic intrusions that host deposits like Eagle. It also tests which rock-chemistry signals actually tell you a deposit is nearby. The Eagle mine was held out of every fitting step. It still ranks at the **98.86th percentile** of the survey area.
37
+
38
+ New here? Read [docs/START_HERE.md](docs/START_HERE.md). Terms are explained in [docs/GLOSSARY.md](docs/GLOSSARY.md).
39
+
40
+ ![Prospectivity map](paper/figures/fig3_prospectivity.png)
41
+
42
+ ## What it found (validation study)
43
+
44
+ | Test | Result |
45
+ |---|---|
46
+ | Held-out Eagle mine, final score | 98.86th percentile; captured within the top 1.14% of the area |
47
+ | Eagle, range across 200 random weightings | 97.6th to 98.77th percentile |
48
+ | Eagle, magnetics only / radiometrics only | 95.6th / 88.44th percentile |
49
+ | Chemistry model (Ni/Cu, MgO, Mg-number), spatial cross-validation in Minnesota | AUC 0.29 with near = 3 km (0.48 at 2 km, 0.35 at 5 km; 0.5 = chance): **does not discriminate, and is unstable** |
50
+ | Cu/Pd within 3 km of a deposit vs more than 10 km away (igneous rocks) | median 21,736 vs 31,568 (n = 330 vs 138) |
51
+ | Targets (top 2% of non-edge squares) | 1,629 targets covering 751.8 km² |
52
+
53
+ Interpretation and caveats: [docs/LIMITATIONS.md](docs/LIMITATIONS.md). Every modeling choice: [docs/DECISIONS.md](docs/DECISIONS.md).
54
+
55
+ ## Interactive map
56
+
57
+ Open it at **https://bkdanso.github.io/riftprospect/**. The source is `site/`, a static Leaflet map: score, uncertainty, the 1,629 targets with popups, known deposits, and Cu/Pd sample labels.
58
+
59
+ ## Install and use
60
+
61
+ ```
62
+ pip install riftprospect
63
+ riftprospect fertility my_samples.csv > fertility.csv
64
+ ```
65
+
66
+ `my_samples.csv` needs columns `Cu_ppm, Ni_ppm, Pd_ppb, Pt_ppb, MgO_wt, Fe2O3T_wt` (negative = below detection, as in MCR-GeoChem). Output: Cu/Pd, Ni/Cu, Pd/Pt, Mg-number and a Cu/Pd fertility class per sample.
67
+
68
+ ## Rebuild everything
69
+
70
+ ```
71
+ ./run_all.sh # about 1 GB of downloads; about 10 minutes
72
+ ```
73
+
74
+ Expected: `paper/stats.json` with 984,215 cells, Eagle at the 98.86th percentile, 1,629 targets, and 14 passing tests.
75
+
76
+ ## Pipeline
77
+
78
+ | Script | Does |
79
+ |---|---|
80
+ | `01_fetch.py`, `01b_fetch_sgmc.py` | Download all sources; log size and SHA-256 to `data/raw/PROVENANCE.txt` |
81
+ | `02_grid.py` | Average the two survey blocks onto one 200 m grid; analytic signal, residual field, edge distance |
82
+ | `03_fertility.py` | Cu/Pd, Ni/Cu, MgO, Mg-number per sample |
83
+ | `05_chem_model.py` | Part A: chemistry model trained in Minnesota, spatial cross-validation (reported, not used on the map) |
84
+ | `06_geophys.py` | Part B: rule-based geophysics score with Monte Carlo uncertainty |
85
+ | `08_combine.py` | Geology mask, targets, held-out Eagle test |
86
+ | `09_validate.py` | All validation numbers into `paper/stats.json` |
87
+ | `04`, `07`, `10` | Figures and the web map |
88
+
89
+ ## Outputs
90
+
91
+ | File | One row is |
92
+ |---|---|
93
+ | `data/processed/targets_ranked.csv` | one target: location, area, peak and mean score, uncertainty, mapped geology, nearby Cu/Pd label |
94
+ | `data/processed/prospectivity_cells.parquet` | one 200 m square with every evidence layer and score |
95
+ | `data/processed/sample_scores.csv` | one rock sample with indicators and chemistry-model score |
96
+ | `paper/stats.json` | every number quoted anywhere |
97
+
98
+ Column definitions: [docs/CODEBOOK.md](docs/CODEBOOK.md).
99
+
100
+ ## Sources
101
+
102
+ | Source | Vintage | License |
103
+ |---|---|---|
104
+ | USGS Critical Mineral Deposits of the United States (Hammarstrom, Woodruff, Dicken and Zach), [doi:10.5066/P9K1HBNT](https://doi.org/10.5066/P9K1HBNT) | ver. 3.0, Sept 2026 | CC0 |
105
+ | USGS Airborne magnetic and radiometric survey, Upper Peninsula, Michigan, and parts of northern Wisconsin, 2025 (Drenth, Murchek, Brown and Connell), [doi:10.5066/P14ESDF7](https://doi.org/10.5066/P14ESDF7) | released Sept 2026 | Public domain |
106
+ | MCR-GeoChem (Danso and Abdullah), [doi:10.5281/zenodo.23516434](https://doi.org/10.5281/zenodo.23516434) | v1.0.1, Oct 2026 | CC BY-SA 4.0 |
107
+ | USGS State Geologic Map Compilation (Horton, San Juan and Stoeser), [doi:10.3133/ds1052](https://doi.org/10.3133/ds1052) | 2017 | Public domain |
108
+
109
+ ## Cite
110
+
111
+ Software: Danso, B.K. (2026). riftprospect v0.1.0: open prospectivity model for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift. Zenodo. https://doi.org/10.5281/zenodo.23697827
112
+
113
+ Target table: Danso, B.K. (2026). Ranked prospectivity targets for magmatic Ni-Cu-Co-PGE deposits, Upper Peninsula of Michigan and northern Wisconsin (v0.1.0). Zenodo. https://doi.org/10.5281/zenodo.23697829
114
+
115
+ See also `CITATION.cff`. Please also cite the sources above.
116
+
117
+ ## AI assistance
118
+
119
+ Claude (Anthropic) assisted with code, tests and drafting. Bezali Kwame Danso made every modeling decision (docs/DECISIONS.md) and verified the outputs.
@@ -0,0 +1,88 @@
1
+ # riftprospect: open prospectivity model for magmatic Ni-Cu-Co-PGE in the Midcontinent Rift
2
+
3
+ **Status:** Released | v0.1.0 | built 2026-10-27 | **Software DOI:** [10.5281/zenodo.23697827](https://doi.org/10.5281/zenodo.23697827) | **Target table DOI:** [10.5281/zenodo.23697829](https://doi.org/10.5281/zenodo.23697829) | **Map:** [https://bkdanso.github.io/riftprospect/](https://bkdanso.github.io/riftprospect/) | **Maintainer:** Bezali Kwame Danso ([ORCID](https://orcid.org/0009-0006-1201-4129)) | Code MIT, data CC BY-SA 4.0
4
+
5
+ **In plain terms:** Nickel, cobalt, copper and the platinum-group metals are on the 2025 U.S. critical minerals list. The Eagle mine near Marquette, Michigan, is the only U.S. mine that produces nickel as its main product, and the rock around Lake Superior could hold more deposits like it, mostly hidden under glacial sediment. This project turns free public data into a ranked list of places worth checking. It scores every 200 m square of the 2025 USGS airborne survey of the Upper Peninsula and northern Wisconsin (39,368.6 km², 984,215 squares) by how much its magnetism and natural radioactivity look like the small, dense, magnetic intrusions that host deposits like Eagle. It also tests which rock-chemistry signals actually tell you a deposit is nearby. The Eagle mine was held out of every fitting step. It still ranks at the **98.86th percentile** of the survey area.
6
+
7
+ New here? Read [docs/START_HERE.md](docs/START_HERE.md). Terms are explained in [docs/GLOSSARY.md](docs/GLOSSARY.md).
8
+
9
+ ![Prospectivity map](paper/figures/fig3_prospectivity.png)
10
+
11
+ ## What it found (validation study)
12
+
13
+ | Test | Result |
14
+ |---|---|
15
+ | Held-out Eagle mine, final score | 98.86th percentile; captured within the top 1.14% of the area |
16
+ | Eagle, range across 200 random weightings | 97.6th to 98.77th percentile |
17
+ | Eagle, magnetics only / radiometrics only | 95.6th / 88.44th percentile |
18
+ | Chemistry model (Ni/Cu, MgO, Mg-number), spatial cross-validation in Minnesota | AUC 0.29 with near = 3 km (0.48 at 2 km, 0.35 at 5 km; 0.5 = chance): **does not discriminate, and is unstable** |
19
+ | Cu/Pd within 3 km of a deposit vs more than 10 km away (igneous rocks) | median 21,736 vs 31,568 (n = 330 vs 138) |
20
+ | Targets (top 2% of non-edge squares) | 1,629 targets covering 751.8 km² |
21
+
22
+ Interpretation and caveats: [docs/LIMITATIONS.md](docs/LIMITATIONS.md). Every modeling choice: [docs/DECISIONS.md](docs/DECISIONS.md).
23
+
24
+ ## Interactive map
25
+
26
+ Open it at **https://bkdanso.github.io/riftprospect/**. The source is `site/`, a static Leaflet map: score, uncertainty, the 1,629 targets with popups, known deposits, and Cu/Pd sample labels.
27
+
28
+ ## Install and use
29
+
30
+ ```
31
+ pip install riftprospect
32
+ riftprospect fertility my_samples.csv > fertility.csv
33
+ ```
34
+
35
+ `my_samples.csv` needs columns `Cu_ppm, Ni_ppm, Pd_ppb, Pt_ppb, MgO_wt, Fe2O3T_wt` (negative = below detection, as in MCR-GeoChem). Output: Cu/Pd, Ni/Cu, Pd/Pt, Mg-number and a Cu/Pd fertility class per sample.
36
+
37
+ ## Rebuild everything
38
+
39
+ ```
40
+ ./run_all.sh # about 1 GB of downloads; about 10 minutes
41
+ ```
42
+
43
+ Expected: `paper/stats.json` with 984,215 cells, Eagle at the 98.86th percentile, 1,629 targets, and 14 passing tests.
44
+
45
+ ## Pipeline
46
+
47
+ | Script | Does |
48
+ |---|---|
49
+ | `01_fetch.py`, `01b_fetch_sgmc.py` | Download all sources; log size and SHA-256 to `data/raw/PROVENANCE.txt` |
50
+ | `02_grid.py` | Average the two survey blocks onto one 200 m grid; analytic signal, residual field, edge distance |
51
+ | `03_fertility.py` | Cu/Pd, Ni/Cu, MgO, Mg-number per sample |
52
+ | `05_chem_model.py` | Part A: chemistry model trained in Minnesota, spatial cross-validation (reported, not used on the map) |
53
+ | `06_geophys.py` | Part B: rule-based geophysics score with Monte Carlo uncertainty |
54
+ | `08_combine.py` | Geology mask, targets, held-out Eagle test |
55
+ | `09_validate.py` | All validation numbers into `paper/stats.json` |
56
+ | `04`, `07`, `10` | Figures and the web map |
57
+
58
+ ## Outputs
59
+
60
+ | File | One row is |
61
+ |---|---|
62
+ | `data/processed/targets_ranked.csv` | one target: location, area, peak and mean score, uncertainty, mapped geology, nearby Cu/Pd label |
63
+ | `data/processed/prospectivity_cells.parquet` | one 200 m square with every evidence layer and score |
64
+ | `data/processed/sample_scores.csv` | one rock sample with indicators and chemistry-model score |
65
+ | `paper/stats.json` | every number quoted anywhere |
66
+
67
+ Column definitions: [docs/CODEBOOK.md](docs/CODEBOOK.md).
68
+
69
+ ## Sources
70
+
71
+ | Source | Vintage | License |
72
+ |---|---|---|
73
+ | USGS Critical Mineral Deposits of the United States (Hammarstrom, Woodruff, Dicken and Zach), [doi:10.5066/P9K1HBNT](https://doi.org/10.5066/P9K1HBNT) | ver. 3.0, Sept 2026 | CC0 |
74
+ | USGS Airborne magnetic and radiometric survey, Upper Peninsula, Michigan, and parts of northern Wisconsin, 2025 (Drenth, Murchek, Brown and Connell), [doi:10.5066/P14ESDF7](https://doi.org/10.5066/P14ESDF7) | released Sept 2026 | Public domain |
75
+ | MCR-GeoChem (Danso and Abdullah), [doi:10.5281/zenodo.23516434](https://doi.org/10.5281/zenodo.23516434) | v1.0.1, Oct 2026 | CC BY-SA 4.0 |
76
+ | USGS State Geologic Map Compilation (Horton, San Juan and Stoeser), [doi:10.3133/ds1052](https://doi.org/10.3133/ds1052) | 2017 | Public domain |
77
+
78
+ ## Cite
79
+
80
+ Software: Danso, B.K. (2026). riftprospect v0.1.0: open prospectivity model for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift. Zenodo. https://doi.org/10.5281/zenodo.23697827
81
+
82
+ Target table: Danso, B.K. (2026). Ranked prospectivity targets for magmatic Ni-Cu-Co-PGE deposits, Upper Peninsula of Michigan and northern Wisconsin (v0.1.0). Zenodo. https://doi.org/10.5281/zenodo.23697829
83
+
84
+ See also `CITATION.cff`. Please also cite the sources above.
85
+
86
+ ## AI assistance
87
+
88
+ Claude (Anthropic) assisted with code, tests and drafting. Bezali Kwame Danso made every modeling decision (docs/DECISIONS.md) and verified the outputs.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "riftprospect"
7
+ version = "0.1.0"
8
+ description = "Open prospectivity modeling for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [{name = "Bezali Kwame Danso"}]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["numpy>=1.24", "pandas>=2.0", "scipy>=1.10", "scikit-learn>=1.3", "rasterio>=1.3",
14
+ "geopandas>=0.14", "pyproj>=3.5", "pyarrow>=12", "openpyxl>=3.1", "matplotlib>=3.7"]
15
+ keywords = ["mineral prospectivity", "critical minerals", "nickel", "platinum-group elements", "Midcontinent Rift", "geochemistry", "aeromagnetics"]
16
+ classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License",
17
+ "Topic :: Scientific/Engineering :: GIS", "Intended Audience :: Science/Research"]
18
+
19
+ [project.optional-dependencies]
20
+ test = ["pytest>=7"]
21
+
22
+ [project.scripts]
23
+ riftprospect = "riftprospect.cli:main"
24
+
25
+ [project.urls]
26
+ Source = "https://github.com/bkdanso/riftprospect"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """riftprospect: open prospectivity modeling for magmatic Ni-Cu-Co-PGE in the Midcontinent Rift."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,105 @@
1
+ """Part A: chemistry similarity model, trained where deposits and samples coexist (Minnesota).
2
+
3
+ Plain-language note: this learns how rock chemistry near known Ni-Cu-PGE deposits differs
4
+ from rock chemistry far from them, then scores other samples by that similarity. It uses
5
+ many resampled copies of the training data (bootstrap) so each score carries a spread.
6
+ Choices marked DECISION are listed in docs/DECISIONS.md.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from sklearn.linear_model import LogisticRegression
13
+ from sklearn.pipeline import make_pipeline
14
+ from sklearn.preprocessing import StandardScaler
15
+ from sklearn.metrics import roc_auc_score
16
+
17
+ # DECISION D2: samples within NEAR_KM of a deposit are positives; beyond FAR_KM negatives;
18
+ # between is a buffer left out of training (it is neither clearly near nor clearly far).
19
+ NEAR_KM, FAR_KM = 3.0, 10.0 # D2 decided by Bezali 2026-10-27: 3 km
20
+ # DECISION D9: model features. Cu/Pd is excluded from the main model because Pd was analyzed
21
+ # far more often near deposits than away from them, so its presence alone leaks the label.
22
+ FEATURES = ["log_ni_cu", "MgO_wt", "mg_number"]
23
+ # DECISION D8: rocks used.
24
+ ROCK_CLASSES = ["igneous_intrusive"]
25
+
26
+
27
+ def add_features(df: pd.DataFrame) -> pd.DataFrame:
28
+ df = df.copy()
29
+ df["log_ni_cu"] = np.log10(df["ni_cu"].where(df["ni_cu"] > 0))
30
+ df["log_cu_pd"] = np.log10(df["cu_pd"].where(df["cu_pd"] > 0))
31
+ return df
32
+
33
+
34
+ def nearest_deposit(xy: np.ndarray, dep_xy: np.ndarray, names) -> tuple[np.ndarray, np.ndarray]:
35
+ d = np.hypot(xy[:, :1] - dep_xy[None, :, 0], xy[:, 1:] - dep_xy[None, :, 1])
36
+ return d.min(1) / 1000.0, np.asarray(names)[d.argmin(1)]
37
+
38
+
39
+ def training_table(samples: pd.DataFrame) -> pd.DataFrame:
40
+ """Rows usable for training: chosen rock classes, not altered, all features present, outside buffer."""
41
+ s = add_features(samples)
42
+ s = s[s.rock_class.isin(ROCK_CLASSES) & ~s.altered_likely.fillna(False).astype(bool)]
43
+ s = s.dropna(subset=FEATURES)
44
+ s = s[(s.dep_km <= NEAR_KM) | (s.dep_km > FAR_KM)].copy()
45
+ s["label"] = (s.dep_km <= NEAR_KM).astype(int)
46
+ return s
47
+
48
+
49
+ def spatial_groups(df: pd.DataFrame, block_km: float = 20.0) -> np.ndarray:
50
+ """Group id per row for spatial cross-validation: positives by nearest deposit, negatives by block."""
51
+ blk = (np.floor(df.x / (block_km * 1000)).astype(int).astype(str) + "_" +
52
+ np.floor(df.y / (block_km * 1000)).astype(int).astype(str))
53
+ return np.where(df.label == 1, "dep_" + df.dep_name.astype(str), "blk_" + blk)
54
+
55
+
56
+ def make_model():
57
+ return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=1000))
58
+
59
+
60
+ def spatial_cv_auc(train: pd.DataFrame, n_folds: int = 5, seed: int = 0) -> dict:
61
+ """Grouped spatial CV: whole deposits and whole 20 km blocks are held out together."""
62
+ from sklearn.model_selection import GroupKFold
63
+ g = spatial_groups(train)
64
+ X, y = train[FEATURES].values, train.label.values
65
+ oof = np.full(len(y), np.nan)
66
+ for tr, te in GroupKFold(n_splits=n_folds).split(X, y, g):
67
+ if len(np.unique(y[tr])) < 2:
68
+ continue
69
+ m = make_model().fit(X[tr], y[tr])
70
+ oof[te] = m.predict_proba(X[te])[:, 1]
71
+ ok = ~np.isnan(oof)
72
+ return {"auc": float(roc_auc_score(y[ok], oof[ok])), "n": int(ok.sum()),
73
+ "n_pos": int(y[ok].sum()), "n_groups": int(len(np.unique(g)))}
74
+
75
+
76
+ def leave_one_deposit_out(train: pd.DataFrame) -> pd.DataFrame:
77
+ """Hold out each deposit's positives plus negatives; report the held-out positives' median rank."""
78
+ out = []
79
+ neg = train[train.label == 0]
80
+ for dep in sorted(train.loc[train.label == 1, "dep_name"].unique()):
81
+ te_pos = train[(train.label == 1) & (train.dep_name == dep)]
82
+ tr = train.drop(te_pos.index)
83
+ m = make_model().fit(tr[FEATURES].values, tr.label.values)
84
+ p_pos = m.predict_proba(te_pos[FEATURES].values)[:, 1]
85
+ p_neg = m.predict_proba(neg[FEATURES].values)[:, 1] # in-sample for negatives: optimistic
86
+ pct = [(p_neg < p).mean() * 100 for p in p_pos]
87
+ out.append({"deposit": dep, "n_heldout": len(te_pos), "median_pct_vs_neg": float(np.median(pct))})
88
+ return pd.DataFrame(out)
89
+
90
+
91
+ def bootstrap_predict(train: pd.DataFrame, apply: pd.DataFrame, n_boot: int = 200, seed: int = 0):
92
+ """Mean and standard deviation of predicted similarity across bootstrap refits."""
93
+ rng = np.random.default_rng(seed)
94
+ X, y = train[FEATURES].values, train.label.values
95
+ Xa = apply[FEATURES].values
96
+ preds = np.empty((n_boot, len(Xa)))
97
+ coefs = []
98
+ for b in range(n_boot):
99
+ i = rng.integers(0, len(y), len(y))
100
+ while len(np.unique(y[i])) < 2:
101
+ i = rng.integers(0, len(y), len(y))
102
+ m = make_model().fit(X[i], y[i])
103
+ preds[b] = m.predict_proba(Xa)[:, 1]
104
+ coefs.append(m[-1].coef_[0])
105
+ return preds.mean(0), preds.std(0), np.array(coefs)
@@ -0,0 +1,35 @@
1
+ """Command line: `riftprospect fertility my_table.csv` scores your own samples.
2
+
3
+ Plain-language note: lets anyone compute the fertility indicators (Cu/Pd, Ni/Cu, MgO,
4
+ Mg-number, Cu/Pd class) on their own spreadsheet laid out like MCR-GeoChem's wide table.
5
+ """
6
+ import argparse
7
+ import sys
8
+
9
+ import pandas as pd
10
+
11
+ from . import __version__
12
+ from .fertility import indicators, cu_pd_class
13
+
14
+
15
+ def main(argv=None):
16
+ ap = argparse.ArgumentParser(prog="riftprospect", description=__doc__)
17
+ ap.add_argument("--version", action="version", version=__version__)
18
+ sub = ap.add_subparsers(dest="cmd", required=True)
19
+ f = sub.add_parser("fertility", help="compute fertility indicators for a CSV of samples")
20
+ f.add_argument("csv"); f.add_argument("--out", default="-")
21
+ f.add_argument("--id-col", default="mcr_uid")
22
+ f.add_argument("--censored", choices=["drop", "half"], default="drop")
23
+ a = ap.parse_args(argv)
24
+ df = pd.read_csv(a.csv, low_memory=False)
25
+ need = ["Cu_ppm", "Ni_ppm", "Pd_ppb", "Pt_ppb", "MgO_wt", "Fe2O3T_wt"]
26
+ missing = [c for c in need if c not in df.columns]
27
+ if missing:
28
+ sys.exit(f"missing columns: {', '.join(missing)} (units: Cu, Ni ppm; Pd, Pt ppb; oxides wt%)")
29
+ out = indicators(df.rename(columns={a.id_col: "mcr_uid"}), rule=a.censored)
30
+ out["cu_pd_class"] = cu_pd_class(out.cu_pd)
31
+ out.rename(columns={"mcr_uid": a.id_col}).to_csv(sys.stdout if a.out == "-" else a.out, index=False)
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -0,0 +1,69 @@
1
+ """Combine chemistry and geophysics on the grid, and turn high-scoring cells into ranked targets.
2
+
3
+ Plain-language note: each 200 m square gets a geophysics score everywhere, and a chemistry
4
+ score only where rock samples are close enough. Where chemistry exists the two are blended;
5
+ where it does not, the square is marked "geophysics only". Adjacent high-scoring squares are
6
+ grouped into targets and ranked. Choices marked DECISION are in docs/DECISIONS.md.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from scipy.spatial import cKDTree
13
+
14
+ CHEM_RADIUS_KM = 5.0 # DECISION D10: how far a sample's chemistry is allowed to reach
15
+ CHEM_WEIGHT = 0.3 # DECISION D10: share of the blended score from chemistry
16
+ TARGET_TOP_PCT = 2.0 # DECISION D7: cells in the top 2% of combined score become target cells
17
+ EDGE_M = 1000.0 # QA rule: cells within 1 km of a data edge cannot become targets (derivative artifacts)
18
+ MIN_TARGET_CELLS = 1 # DECISION D7: single 200 m cells kept, because Eagle-type intrusions are about one cell across (changed from 3 after Eagle dropped out: disclosed)
19
+
20
+
21
+ def chem_to_grid(cells_xy, samp_xy, samp_mean, samp_sd, radius_km=CHEM_RADIUS_KM, power=2.0):
22
+ """Inverse-distance-weighted chemistry score per cell from samples within `radius_km`."""
23
+ n = len(cells_xy)
24
+ mean = np.full(n, np.nan); sd = np.full(n, np.nan); count = np.zeros(n, dtype=int)
25
+ if len(samp_xy) == 0:
26
+ return mean, sd, count
27
+ tree = cKDTree(samp_xy)
28
+ nbrs = tree.query_ball_point(cells_xy, r=radius_km * 1000)
29
+ for i, js in enumerate(nbrs):
30
+ if not js:
31
+ continue
32
+ d = np.hypot(*(samp_xy[js] - cells_xy[i]).T)
33
+ w = 1.0 / np.maximum(d, 100.0) ** power
34
+ w /= w.sum()
35
+ mean[i] = (w * samp_mean[js]).sum()
36
+ sd[i] = np.sqrt((w * samp_sd[js] ** 2).sum())
37
+ count[i] = len(js)
38
+ return mean, sd, count
39
+
40
+
41
+ def blend(g_mean, g_sd, c_mean, c_sd, wc=CHEM_WEIGHT):
42
+ has = ~np.isnan(c_mean)
43
+ m = g_mean.copy(); s = g_sd.copy()
44
+ m[has] = (1 - wc) * g_mean[has] + wc * c_mean[has]
45
+ s[has] = np.sqrt(((1 - wc) * g_sd[has]) ** 2 + (wc * c_sd[has]) ** 2)
46
+ return m, s, np.where(has, "geophysics+chemistry", "geophysics only")
47
+
48
+
49
+ def targets(grid: pd.DataFrame, shape, score_col="score", top_pct=TARGET_TOP_PCT,
50
+ min_cells=MIN_TARGET_CELLS) -> tuple[pd.DataFrame, np.ndarray]:
51
+ """Connected groups (8-neighbour) of cells above the top-`top_pct` threshold (computed on non-edge
52
+ cells), excluding edge cells, ranked by peak score then size."""
53
+ from scipy.ndimage import label
54
+ thr = np.nanpercentile(grid.loc[grid["edge_dist_m"] >= EDGE_M, score_col], 100 - top_pct)
55
+ img = np.zeros(shape, bool)
56
+ hi = (grid[score_col] >= thr) & (grid["edge_dist_m"] >= EDGE_M)
57
+ img[grid.row[hi], grid.col[hi]] = True
58
+ lab, _ = label(img, structure=np.ones((3, 3)))
59
+ g = grid.assign(target=lab[grid.row, grid.col])
60
+ g = g[g.target > 0]
61
+ agg = g.groupby("target").agg(
62
+ n_cells=("cell_id", "size"), score_mean=(score_col, "mean"), score_max=(score_col, "max"),
63
+ score_sd_mean=("score_sd", "mean"), x=("x", "mean"), y=("y", "mean"),
64
+ block=("block", "first"))
65
+ agg = agg[agg.n_cells >= min_cells].sort_values(["score_max", "n_cells"], ascending=False)
66
+ agg["rank"] = np.arange(1, len(agg) + 1)
67
+ keep = set(agg.index)
68
+ lab[~np.isin(lab, list(keep))] = 0
69
+ return agg.reset_index(), lab
@@ -0,0 +1,82 @@
1
+ """Geochemical fertility indicators for magmatic Ni-Cu-PGE sulfide systems.
2
+
3
+ Plain-language note: this file computes the chemistry ratios you named (Cu/Pd,
4
+ Ni/Cu, MgO, Mg-number) for each rock sample, and records whether each value is a
5
+ real measurement or rests on a "below detection" value. The provisional choices
6
+ (marked DECISION) are listed in docs/DECISIONS.md for you to rule on.
7
+
8
+ Conventions follow MCR-GeoChem's wide table: Pd and Pt in ppb, Cu and Ni in ppm,
9
+ oxides in wt%, and a negative number means "below detection, limit = |value|".
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ MW = {"MgO": 40.304, "FeO": 71.844, "Fe2O3": 159.688}
17
+
18
+ # DECISION D3: share of total iron taken as Fe2+ for Mg-number (0.9 is a common choice for
19
+ # basalts near the FMQ buffer; 0.85 and 1.0 are also used).
20
+ FE2_FRACTION = 0.9
21
+
22
+ # DECISION D4: how to treat a below-detection value in a ratio.
23
+ # "drop": ratio is NaN. "half": use half the limit and flag the ratio as censored.
24
+ CENSORED_RULE = "drop"
25
+
26
+
27
+ def _value(series, rule=CENSORED_RULE):
28
+ s = pd.to_numeric(series, errors="coerce")
29
+ censored = s < 0
30
+ if rule == "drop":
31
+ v = s.where(s > 0)
32
+ elif rule == "half":
33
+ v = s.where(s > 0, s.abs() / 2).where(s != 0)
34
+ else:
35
+ raise ValueError(rule)
36
+ return v, censored
37
+
38
+
39
+ def mg_number(mgo_wt, fe2o3t_wt, fe2_fraction=FE2_FRACTION):
40
+ """Molar 100*Mg/(Mg+Fe2+), with Fe2+ = fe2_fraction * total Fe (from Fe2O3T)."""
41
+ mg = mgo_wt / MW["MgO"]
42
+ fet = 2 * fe2o3t_wt / MW["Fe2O3"]
43
+ return 100 * mg / (mg + fe2_fraction * fet)
44
+
45
+
46
+ def indicators(wide: pd.DataFrame, rule: str = CENSORED_RULE) -> pd.DataFrame:
47
+ """Return one row per sample with Cu/Pd, Ni/Cu, Pd/Pt, MgO, Mg#, and censoring flags."""
48
+ cu, cu_c = _value(wide["Cu_ppm"], rule)
49
+ ni, ni_c = _value(wide["Ni_ppm"], rule)
50
+ pd_, pd_c = _value(wide["Pd_ppb"], rule)
51
+ pt, pt_c = _value(wide["Pt_ppb"], rule)
52
+ mgo, _ = _value(wide["MgO_wt"], "drop")
53
+ fe, _ = _value(wide["Fe2O3T_wt"], "drop")
54
+ out = pd.DataFrame({"mcr_uid": wide["mcr_uid"]})
55
+ out["Cu_ppm"], out["Ni_ppm"], out["Pd_ppb"], out["Pt_ppb"] = cu, ni, pd_, pt
56
+ out["MgO_wt"] = mgo
57
+ out["mg_number"] = mg_number(mgo, fe)
58
+ # Cu/Pd uses both in ppm; primitive mantle about 7,000 (see D5).
59
+ out["cu_pd"] = cu / (pd_ / 1000.0)
60
+ out["ni_cu"] = ni / cu
61
+ out["pd_pt"] = pd_ / pt
62
+ out["cu_pd_censored"] = cu_c | pd_c
63
+ out["ni_cu_censored"] = ni_c | cu_c
64
+ return out
65
+
66
+
67
+ # DECISION D5 (kept by Bezali 2026-10-27): fertility classes from Cu/Pd. Primitive-mantle Cu/Pd is
68
+ # taken as 7,000, rounded from McDonough and Sun (1995) primitive mantle Cu 30 ppm and Pd 3.9 ppb
69
+ # (ratio about 7,700). Values well above mantle mean the magma lost PGE to sulfide earlier
70
+ # (PGE-depleted, sulfide saturation upstream); values below mantle mean PGE-enriched
71
+ # (sulfide-bearing or cumulate). Thresholds below are provisional.
72
+ CU_PD_MANTLE = 7000.0
73
+ CU_PD_DEPLETED = 3 * CU_PD_MANTLE # above this: PGE-depleted
74
+ CU_PD_ENRICHED = CU_PD_MANTLE / 3 # below this: PGE-enriched
75
+
76
+
77
+ def cu_pd_class(cu_pd: pd.Series) -> pd.Series:
78
+ c = pd.Series("undetermined", index=cu_pd.index, dtype="object")
79
+ c[cu_pd > CU_PD_DEPLETED] = "pge_depleted"
80
+ c[(cu_pd <= CU_PD_DEPLETED) & (cu_pd >= CU_PD_ENRICHED)] = "mantle_like"
81
+ c[cu_pd < CU_PD_ENRICHED] = "pge_enriched"
82
+ return c
@@ -0,0 +1,53 @@
1
+ """Geology mask (decision D11): down-weight rock types that are magnetic but not Ni-Cu hosts.
2
+
3
+ Plain-language note: iron formation and the thick Keweenawan lava pile are strongly magnetic,
4
+ so the geophysics score flags them even though magmatic nickel deposits are not hosted there.
5
+ This module paints each 200 m square with its mapped rock type from the USGS State Geologic
6
+ Map Compilation and applies a multiplier. Multipliers are DECISION D11.
7
+ """
8
+ from __future__ import annotations
9
+ import numpy as np
10
+ import geopandas as gpd
11
+ from rasterio.features import rasterize
12
+
13
+ # class code: (label, multiplier)
14
+ CLASSES = {0: ("other or unmapped", 1.0),
15
+ 1: ("iron formation (+500 m)", 0.3),
16
+ 2: ("Mesoproterozoic volcanic", 0.6),
17
+ 3: ("intrusive", 1.0),
18
+ 4: ("Phanerozoic sedimentary cover", 1.0)} # D12: kept at 1.0 but flagged; depth to target unknown
19
+ PHANEROZOIC = "Cambrian|Ordovician|Silurian|Devonian|Mississippian|Pennsylvanian|Permian|Jurassic|Cretaceous|Paleozoic"
20
+ IF_BUFFER_M = 500.0
21
+
22
+
23
+ def classify(sgmc: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
24
+ g = sgmc.copy()
25
+ lith = g.GENERALIZED_LITH.fillna("")
26
+ iron = lith.str.contains("iron formation", case=False) | g.MAJOR1.fillna("").str.contains("iron-formation", case=False)
27
+ meso = (g.AGE_MIN.fillna("") + g.AGE_MAX.fillna("")).str.contains("Mesoproterozoic")
28
+ volc = lith.str.contains("volcanic", case=False) & meso
29
+ intr = lith.str.contains("intrusive", case=False)
30
+ phan = g.AGE_MIN.fillna("").str.contains(PHANEROZOIC) & lith.str.contains("Sedimentary")
31
+ g["geo_class"] = np.select([iron, intr, volc, phan], [1, 3, 2, 4], 0)
32
+ return g
33
+
34
+
35
+ def rasterize_classes(g: gpd.GeoDataFrame, transform, shape) -> np.ndarray:
36
+ """Paint class codes in increasing priority: volcanic, intrusive, then buffered iron formation on top."""
37
+ out = np.zeros(shape, dtype="uint8")
38
+ for code in (4, 2, 3):
39
+ sub = g[g.geo_class == code]
40
+ if len(sub):
41
+ out = np.where(rasterize(((geom, 1) for geom in sub.geometry), out_shape=shape,
42
+ transform=transform, fill=0, dtype="uint8") == 1, code, out)
43
+ iron = g[g.geo_class == 1]
44
+ if len(iron):
45
+ m = rasterize(((geom.buffer(IF_BUFFER_M), 1) for geom in iron.geometry), out_shape=shape,
46
+ transform=transform, fill=0, dtype="uint8")
47
+ out[m == 1] = 1
48
+ return out
49
+
50
+
51
+ def multiplier(codes: np.ndarray) -> np.ndarray:
52
+ lut = np.array([CLASSES[k][1] for k in sorted(CLASSES)], dtype="float32")
53
+ return lut[codes]
@@ -0,0 +1,72 @@
1
+ """Part B: rule-based geophysical evidence for small mafic-ultramafic intrusions.
2
+
3
+ Plain-language note: magmatic nickel deposits like Eagle sit in small, dense, magnetic
4
+ intrusions of mafic and ultramafic rock, which are low in potassium and thorium. This file
5
+ turns each airborne measurement into a 0-to-1 "how much does this square look like that"
6
+ score, then combines them with weights. Weights are DECISION D6 (yours). Uncertainty comes
7
+ from re-running with randomly varied weights and transition points (Monte Carlo).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ # Evidence layers: (column, direction). +1 = higher is more favorable; -1 = lower is.
15
+ # Rationale (to be checked by Bezali, D6):
16
+ # rtp_asig : strong magnetic gradients mark discrete magnetic bodies (pyrrhotite/magnetite-bearing intrusions)
17
+ # rtp_resid : magnetic high relative to the surrounding 2 km
18
+ # k, th : mafic-ultramafic rocks are poor in K and Th
19
+ LAYERS = {"rtp_asig": +1, "rtp_resid": +1, "k": -1, "th": -1}
20
+ # DECISION D6: provisional equal-ish weights, magnetics favored because cover attenuates gamma rays.
21
+ WEIGHTS = {"rtp_asig": 0.35, "rtp_resid": 0.35, "k": 0.15, "th": 0.15}
22
+ MIDPOINT_PCT = 75.0 # logistic membership centred at this percentile (direction-adjusted)
23
+ SPREAD_PCT = 10.0
24
+
25
+
26
+ def percentile_rank(v: np.ndarray) -> np.ndarray:
27
+ """0-100 percentile of each value within the survey (NaN stays NaN)."""
28
+ out = np.full(v.shape, np.nan)
29
+ ok = ~np.isnan(v)
30
+ order = v[ok].argsort().argsort()
31
+ out[ok] = 100.0 * order / max(ok.sum() - 1, 1)
32
+ return out
33
+
34
+
35
+ def membership(pct: np.ndarray, direction: int, mid=MIDPOINT_PCT, spread=SPREAD_PCT) -> np.ndarray:
36
+ p = pct if direction > 0 else 100.0 - pct
37
+ return 1.0 / (1.0 + np.exp(-(p - mid) / spread))
38
+
39
+
40
+ def evidence(df: pd.DataFrame, by_block: bool = True) -> pd.DataFrame:
41
+ """Percentile ranks per layer. Ranked within each survey block because the blocks differ
42
+ in line spacing (150 m vs 400 m) and therefore in how sharp the gradients are."""
43
+ out = pd.DataFrame(index=df.index)
44
+ groups = df.groupby("block").groups if by_block else {"all": df.index}
45
+ for col in LAYERS:
46
+ r = pd.Series(np.nan, index=df.index)
47
+ for _, idx in groups.items():
48
+ r.loc[idx] = percentile_rank(df.loc[idx, col].to_numpy(dtype=float))
49
+ out[col] = r
50
+ return out
51
+
52
+
53
+ def score(pct: pd.DataFrame, weights=None, mid=MIDPOINT_PCT, spread=SPREAD_PCT) -> np.ndarray:
54
+ """Weighted mean of memberships; a missing layer is skipped and the weights renormalized."""
55
+ weights = weights or WEIGHTS
56
+ num = np.zeros(len(pct)); den = np.zeros(len(pct))
57
+ for col, d in LAYERS.items():
58
+ m = membership(pct[col].to_numpy(), d, mid, spread)
59
+ ok = ~np.isnan(m)
60
+ num[ok] += weights[col] * m[ok]; den[ok] += weights[col]
61
+ return num / np.where(den > 0, den, np.nan)
62
+
63
+
64
+ def monte_carlo(pct: pd.DataFrame, n: int = 100, seed: int = 0, concentration: float = 20.0):
65
+ """Mean and SD of the score when weights ~ Dirichlet around WEIGHTS and the midpoint ~ U(65, 85)."""
66
+ rng = np.random.default_rng(seed)
67
+ base = np.array([WEIGHTS[c] for c in LAYERS])
68
+ runs = np.empty((n, len(pct)), dtype="float32")
69
+ for i in range(n):
70
+ w = dict(zip(LAYERS, rng.dirichlet(base * concentration)))
71
+ runs[i] = score(pct, w, mid=rng.uniform(65, 85))
72
+ return runs.mean(0), runs.std(0)
@@ -0,0 +1,94 @@
1
+ """Put airborne geophysical grids from several survey blocks onto one common grid.
2
+
3
+ Plain-language note: the USGS survey comes as two blocks with different cell sizes.
4
+ This module averages each block onto the same 200 m squares so every square has
5
+ one value per measurement. You do not need to open this file.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import rasterio
11
+ from rasterio.enums import Resampling
12
+ from rasterio.transform import from_origin
13
+ from rasterio.warp import reproject
14
+
15
+
16
+ def common_transform(bounds_list, cell):
17
+ """Return (transform, height, width) of a grid that covers all bounds, snapped to `cell`."""
18
+ left = np.floor(min(b[0] for b in bounds_list) / cell) * cell
19
+ bottom = np.floor(min(b[1] for b in bounds_list) / cell) * cell
20
+ right = np.ceil(max(b[2] for b in bounds_list) / cell) * cell
21
+ top = np.ceil(max(b[3] for b in bounds_list) / cell) * cell
22
+ width = int(round((right - left) / cell))
23
+ height = int(round((top - bottom) / cell))
24
+ return from_origin(left, top, cell, cell), height, width
25
+
26
+
27
+ def mosaic_average(paths, transform, shape, crs):
28
+ """Average-resample each raster in `paths` onto the target grid; later files fill gaps only.
29
+
30
+ Returns a float32 array with NaN where no source has data. Each target cell is
31
+ the mean of the source cells it covers (rasterio Resampling.average), so finer
32
+ blocks are smoothed, not subsampled.
33
+ """
34
+ out = np.full(shape, np.nan, dtype="float32")
35
+ for p in paths:
36
+ with rasterio.open(p) as src:
37
+ src_arr = src.read(1).astype("float32")
38
+ nod = src.nodata
39
+ if nod is not None:
40
+ src_arr[src_arr == nod] = np.nan
41
+ tmp = np.full(shape, np.nan, dtype="float32")
42
+ reproject(src_arr, tmp, src_transform=src.transform, src_crs=src.crs,
43
+ dst_transform=transform, dst_crs=crs, src_nodata=np.nan,
44
+ dst_nodata=np.nan, resampling=Resampling.average)
45
+ fill = np.isnan(out) & ~np.isnan(tmp)
46
+ out[fill] = tmp[fill]
47
+ return out
48
+
49
+
50
+ def analytic_signal(field, cell):
51
+ """Total-gradient (3D analytic signal) amplitude of a potential-field grid.
52
+
53
+ Horizontal derivatives by central differences; the vertical derivative by the
54
+ FFT |k| filter on a NaN-filled copy. Edges within a few cells of data gaps are
55
+ less reliable; cells that were NaN stay NaN.
56
+ """
57
+ f = field.astype("float64")
58
+ mask = np.isnan(f)
59
+ fill = np.where(mask, np.nanmean(f), f)
60
+ dy, dx = np.gradient(fill, cell)
61
+ ky = np.fft.fftfreq(f.shape[0], d=cell) * 2 * np.pi
62
+ kx = np.fft.fftfreq(f.shape[1], d=cell) * 2 * np.pi
63
+ k = np.sqrt(kx[None, :] ** 2 + ky[:, None] ** 2)
64
+ dz = np.real(np.fft.ifft2(np.fft.fft2(fill - fill.mean()) * k))
65
+ asig = np.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
66
+ asig[mask] = np.nan
67
+ return asig.astype("float32")
68
+
69
+
70
+ def local_std(field, radius_cells):
71
+ """Standard deviation in a square window (2r+1 cells), ignoring NaN. A texture measure."""
72
+ from scipy.ndimage import uniform_filter
73
+ f = field.astype("float64")
74
+ valid = ~np.isnan(f)
75
+ z = np.where(valid, f, 0.0)
76
+ size = 2 * radius_cells + 1
77
+ n = uniform_filter(valid.astype("float64"), size)
78
+ m1 = uniform_filter(z, size) / np.where(n > 0, n, np.nan)
79
+ m2 = uniform_filter(z * z, size) / np.where(n > 0, n, np.nan)
80
+ sd = np.sqrt(np.clip(m2 - m1 ** 2, 0, None))
81
+ sd[~valid] = np.nan
82
+ return sd.astype("float32")
83
+
84
+
85
+ def local_mean(field, radius_cells):
86
+ """Mean in a square window (2r+1 cells), ignoring NaN."""
87
+ from scipy.ndimage import uniform_filter
88
+ f = field.astype("float64")
89
+ valid = ~np.isnan(f)
90
+ size = 2 * radius_cells + 1
91
+ n = uniform_filter(valid.astype("float64"), size)
92
+ m = uniform_filter(np.where(valid, f, 0.0), size) / np.where(n > 0, n, np.nan)
93
+ m[~valid] = np.nan
94
+ return m.astype("float32")
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: riftprospect
3
+ Version: 0.1.0
4
+ Summary: Open prospectivity modeling for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift
5
+ Author: Bezali Kwame Danso
6
+ License: MIT
7
+ Project-URL: Source, https://github.com/bkdanso/riftprospect
8
+ Keywords: mineral prospectivity,critical minerals,nickel,platinum-group elements,Midcontinent Rift,geochemistry,aeromagnetics
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Topic :: Scientific/Engineering :: GIS
12
+ Classifier: Intended Audience :: Science/Research
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: LICENSE-DATA.md
17
+ License-File: AUTHORS.json
18
+ Requires-Dist: numpy>=1.24
19
+ Requires-Dist: pandas>=2.0
20
+ Requires-Dist: scipy>=1.10
21
+ Requires-Dist: scikit-learn>=1.3
22
+ Requires-Dist: rasterio>=1.3
23
+ Requires-Dist: geopandas>=0.14
24
+ Requires-Dist: pyproj>=3.5
25
+ Requires-Dist: pyarrow>=12
26
+ Requires-Dist: openpyxl>=3.1
27
+ Requires-Dist: matplotlib>=3.7
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest>=7; extra == "test"
30
+ Dynamic: license-file
31
+
32
+ # riftprospect: open prospectivity model for magmatic Ni-Cu-Co-PGE in the Midcontinent Rift
33
+
34
+ **Status:** Released | v0.1.0 | built 2026-10-27 | **Software DOI:** [10.5281/zenodo.23697827](https://doi.org/10.5281/zenodo.23697827) | **Target table DOI:** [10.5281/zenodo.23697829](https://doi.org/10.5281/zenodo.23697829) | **Map:** [https://bkdanso.github.io/riftprospect/](https://bkdanso.github.io/riftprospect/) | **Maintainer:** Bezali Kwame Danso ([ORCID](https://orcid.org/0009-0006-1201-4129)) | Code MIT, data CC BY-SA 4.0
35
+
36
+ **In plain terms:** Nickel, cobalt, copper and the platinum-group metals are on the 2025 U.S. critical minerals list. The Eagle mine near Marquette, Michigan, is the only U.S. mine that produces nickel as its main product, and the rock around Lake Superior could hold more deposits like it, mostly hidden under glacial sediment. This project turns free public data into a ranked list of places worth checking. It scores every 200 m square of the 2025 USGS airborne survey of the Upper Peninsula and northern Wisconsin (39,368.6 km², 984,215 squares) by how much its magnetism and natural radioactivity look like the small, dense, magnetic intrusions that host deposits like Eagle. It also tests which rock-chemistry signals actually tell you a deposit is nearby. The Eagle mine was held out of every fitting step. It still ranks at the **98.86th percentile** of the survey area.
37
+
38
+ New here? Read [docs/START_HERE.md](docs/START_HERE.md). Terms are explained in [docs/GLOSSARY.md](docs/GLOSSARY.md).
39
+
40
+ ![Prospectivity map](paper/figures/fig3_prospectivity.png)
41
+
42
+ ## What it found (validation study)
43
+
44
+ | Test | Result |
45
+ |---|---|
46
+ | Held-out Eagle mine, final score | 98.86th percentile; captured within the top 1.14% of the area |
47
+ | Eagle, range across 200 random weightings | 97.6th to 98.77th percentile |
48
+ | Eagle, magnetics only / radiometrics only | 95.6th / 88.44th percentile |
49
+ | Chemistry model (Ni/Cu, MgO, Mg-number), spatial cross-validation in Minnesota | AUC 0.29 with near = 3 km (0.48 at 2 km, 0.35 at 5 km; 0.5 = chance): **does not discriminate, and is unstable** |
50
+ | Cu/Pd within 3 km of a deposit vs more than 10 km away (igneous rocks) | median 21,736 vs 31,568 (n = 330 vs 138) |
51
+ | Targets (top 2% of non-edge squares) | 1,629 targets covering 751.8 km² |
52
+
53
+ Interpretation and caveats: [docs/LIMITATIONS.md](docs/LIMITATIONS.md). Every modeling choice: [docs/DECISIONS.md](docs/DECISIONS.md).
54
+
55
+ ## Interactive map
56
+
57
+ Open it at **https://bkdanso.github.io/riftprospect/**. The source is `site/`, a static Leaflet map: score, uncertainty, the 1,629 targets with popups, known deposits, and Cu/Pd sample labels.
58
+
59
+ ## Install and use
60
+
61
+ ```
62
+ pip install riftprospect
63
+ riftprospect fertility my_samples.csv > fertility.csv
64
+ ```
65
+
66
+ `my_samples.csv` needs columns `Cu_ppm, Ni_ppm, Pd_ppb, Pt_ppb, MgO_wt, Fe2O3T_wt` (negative = below detection, as in MCR-GeoChem). Output: Cu/Pd, Ni/Cu, Pd/Pt, Mg-number and a Cu/Pd fertility class per sample.
67
+
68
+ ## Rebuild everything
69
+
70
+ ```
71
+ ./run_all.sh # about 1 GB of downloads; about 10 minutes
72
+ ```
73
+
74
+ Expected: `paper/stats.json` with 984,215 cells, Eagle at the 98.86th percentile, 1,629 targets, and 14 passing tests.
75
+
76
+ ## Pipeline
77
+
78
+ | Script | Does |
79
+ |---|---|
80
+ | `01_fetch.py`, `01b_fetch_sgmc.py` | Download all sources; log size and SHA-256 to `data/raw/PROVENANCE.txt` |
81
+ | `02_grid.py` | Average the two survey blocks onto one 200 m grid; analytic signal, residual field, edge distance |
82
+ | `03_fertility.py` | Cu/Pd, Ni/Cu, MgO, Mg-number per sample |
83
+ | `05_chem_model.py` | Part A: chemistry model trained in Minnesota, spatial cross-validation (reported, not used on the map) |
84
+ | `06_geophys.py` | Part B: rule-based geophysics score with Monte Carlo uncertainty |
85
+ | `08_combine.py` | Geology mask, targets, held-out Eagle test |
86
+ | `09_validate.py` | All validation numbers into `paper/stats.json` |
87
+ | `04`, `07`, `10` | Figures and the web map |
88
+
89
+ ## Outputs
90
+
91
+ | File | One row is |
92
+ |---|---|
93
+ | `data/processed/targets_ranked.csv` | one target: location, area, peak and mean score, uncertainty, mapped geology, nearby Cu/Pd label |
94
+ | `data/processed/prospectivity_cells.parquet` | one 200 m square with every evidence layer and score |
95
+ | `data/processed/sample_scores.csv` | one rock sample with indicators and chemistry-model score |
96
+ | `paper/stats.json` | every number quoted anywhere |
97
+
98
+ Column definitions: [docs/CODEBOOK.md](docs/CODEBOOK.md).
99
+
100
+ ## Sources
101
+
102
+ | Source | Vintage | License |
103
+ |---|---|---|
104
+ | USGS Critical Mineral Deposits of the United States (Hammarstrom, Woodruff, Dicken and Zach), [doi:10.5066/P9K1HBNT](https://doi.org/10.5066/P9K1HBNT) | ver. 3.0, Sept 2026 | CC0 |
105
+ | USGS Airborne magnetic and radiometric survey, Upper Peninsula, Michigan, and parts of northern Wisconsin, 2025 (Drenth, Murchek, Brown and Connell), [doi:10.5066/P14ESDF7](https://doi.org/10.5066/P14ESDF7) | released Sept 2026 | Public domain |
106
+ | MCR-GeoChem (Danso and Abdullah), [doi:10.5281/zenodo.23516434](https://doi.org/10.5281/zenodo.23516434) | v1.0.1, Oct 2026 | CC BY-SA 4.0 |
107
+ | USGS State Geologic Map Compilation (Horton, San Juan and Stoeser), [doi:10.3133/ds1052](https://doi.org/10.3133/ds1052) | 2017 | Public domain |
108
+
109
+ ## Cite
110
+
111
+ Software: Danso, B.K. (2026). riftprospect v0.1.0: open prospectivity model for magmatic Ni-Cu-Co-PGE deposits in the Midcontinent Rift. Zenodo. https://doi.org/10.5281/zenodo.23697827
112
+
113
+ Target table: Danso, B.K. (2026). Ranked prospectivity targets for magmatic Ni-Cu-Co-PGE deposits, Upper Peninsula of Michigan and northern Wisconsin (v0.1.0). Zenodo. https://doi.org/10.5281/zenodo.23697829
114
+
115
+ See also `CITATION.cff`. Please also cite the sources above.
116
+
117
+ ## AI assistance
118
+
119
+ Claude (Anthropic) assisted with code, tests and drafting. Bezali Kwame Danso made every modeling decision (docs/DECISIONS.md) and verified the outputs.
@@ -0,0 +1,20 @@
1
+ AUTHORS.json
2
+ LICENSE
3
+ LICENSE-DATA.md
4
+ README.md
5
+ pyproject.toml
6
+ src/riftprospect/__init__.py
7
+ src/riftprospect/chemmodel.py
8
+ src/riftprospect/cli.py
9
+ src/riftprospect/combine.py
10
+ src/riftprospect/fertility.py
11
+ src/riftprospect/geology.py
12
+ src/riftprospect/geophys.py
13
+ src/riftprospect/grid.py
14
+ src/riftprospect.egg-info/PKG-INFO
15
+ src/riftprospect.egg-info/SOURCES.txt
16
+ src/riftprospect.egg-info/dependency_links.txt
17
+ src/riftprospect.egg-info/entry_points.txt
18
+ src/riftprospect.egg-info/requires.txt
19
+ src/riftprospect.egg-info/top_level.txt
20
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ riftprospect = riftprospect.cli:main
@@ -0,0 +1,13 @@
1
+ numpy>=1.24
2
+ pandas>=2.0
3
+ scipy>=1.10
4
+ scikit-learn>=1.3
5
+ rasterio>=1.3
6
+ geopandas>=0.14
7
+ pyproj>=3.5
8
+ pyarrow>=12
9
+ openpyxl>=3.1
10
+ matplotlib>=3.7
11
+
12
+ [test]
13
+ pytest>=7
@@ -0,0 +1 @@
1
+ riftprospect
@@ -0,0 +1,93 @@
1
+ """Tests for riftprospect. Hand-checkable numbers; run with `pytest`."""
2
+ import numpy as np
3
+ import pandas as pd
4
+ import pytest
5
+ from affine import Affine
6
+
7
+ from riftprospect import fertility as fe, geophys as gp, combine as cb, chemmodel as cm, geology as geo
8
+ from riftprospect.grid import common_transform, local_mean, analytic_signal
9
+
10
+
11
+ def wide(**kw):
12
+ base = dict(mcr_uid=["a"], Cu_ppm=[100.0], Ni_ppm=[200.0], Pd_ppb=[10.0], Pt_ppb=[5.0], MgO_wt=[10.0], Fe2O3T_wt=[12.0])
13
+ base.update(kw); return pd.DataFrame(base)
14
+
15
+
16
+ def test_cu_pd_units():
17
+ # Cu 100 ppm, Pd 10 ppb = 0.01 ppm -> Cu/Pd = 10,000
18
+ assert fe.indicators(wide()).cu_pd.iloc[0] == pytest.approx(10000)
19
+
20
+
21
+ def test_ni_cu_and_pd_pt():
22
+ r = fe.indicators(wide()).iloc[0]
23
+ assert r.ni_cu == pytest.approx(2.0) and r.pd_pt == pytest.approx(2.0)
24
+
25
+
26
+ def test_mg_number_by_hand():
27
+ # MgO 10 / 40.304 = 0.24811 mol; FeT = 2*12/159.688 = 0.15030; Fe2+ = 0.9*0.15030 = 0.13527
28
+ assert fe.mg_number(10.0, 12.0) == pytest.approx(100 * 0.24811 / (0.24811 + 0.13527), rel=1e-3)
29
+
30
+
31
+ def test_censored_drop_and_half():
32
+ d = wide(Pd_ppb=[-2.0]) # below detection, limit 2 ppb
33
+ assert np.isnan(fe.indicators(d, rule="drop").cu_pd.iloc[0])
34
+ h = fe.indicators(d, rule="half").iloc[0]
35
+ assert h.cu_pd == pytest.approx(100 / 0.001) and bool(h.cu_pd_censored)
36
+
37
+
38
+ def test_cu_pd_classes():
39
+ c = fe.cu_pd_class(pd.Series([1000.0, 7000.0, 50000.0, np.nan]))
40
+ assert list(c) == ["pge_enriched", "mantle_like", "pge_depleted", "undetermined"]
41
+
42
+
43
+ def test_membership_direction():
44
+ assert gp.membership(np.array([95.0]), +1)[0] > 0.8
45
+ assert gp.membership(np.array([95.0]), -1)[0] < 0.2
46
+
47
+
48
+ def test_percentile_rank_keeps_nan():
49
+ r = gp.percentile_rank(np.array([3.0, np.nan, 1.0, 2.0]))
50
+ assert np.isnan(r[1]) and r[2] == 0 and r[0] == 100
51
+
52
+
53
+ def test_score_renormalizes_missing_layer():
54
+ pct = pd.DataFrame({"rtp_asig": [100.0], "rtp_resid": [100.0], "k": [np.nan], "th": [np.nan]})
55
+ s = gp.score(pct)[0]
56
+ assert s == pytest.approx(gp.membership(np.array([100.0]), 1)[0])
57
+
58
+
59
+ def test_common_transform_snaps():
60
+ tr, h, w = common_transform([(10, 10, 390, 190)], 200)
61
+ assert (tr.c, tr.f, h, w) == (0, 200, 1, 2)
62
+
63
+
64
+ def test_local_mean_flat_field():
65
+ f = np.ones((7, 7), "float32"); f[0, 0] = np.nan
66
+ m = local_mean(f, 1)
67
+ assert np.isnan(m[0, 0]) and m[3, 3] == pytest.approx(1)
68
+
69
+
70
+ def test_analytic_signal_constant_is_zero():
71
+ a = analytic_signal(np.full((16, 16), 5.0), 200)
72
+ assert np.nanmax(a) == pytest.approx(0, abs=1e-9)
73
+
74
+
75
+ def test_targets_edge_and_ranking():
76
+ grid = pd.DataFrame({"row": [0, 0, 1, 2], "col": [0, 1, 1, 3], "cell_id": list("abcd"), "x": [0, 1, 1, 3.0], "y": [0, 0, 1, 2.0],
77
+ "score": [0.9, 0.8, 0.1, 0.95], "score_sd": [0.1] * 4, "edge_dist_m": [5000, 5000, 5000, 100],
78
+ "evidence": ["g"] * 4, "block": ["A"] * 4})
79
+ t, lab = cb.targets(grid, (3, 4), top_pct=50, min_cells=1)
80
+ assert len(t) == 1 and t.n_cells.iloc[0] == 2 # the edge cell (d) is excluded
81
+ assert lab[2, 3] == 0
82
+
83
+
84
+ def test_training_table_buffer_excluded():
85
+ df = pd.DataFrame({"rock_class": ["igneous_intrusive"] * 3, "altered_likely": [False] * 3,
86
+ "ni_cu": [1, 1, 1.0], "cu_pd": [1, 1, 1.0], "MgO_wt": [5, 5, 5.0], "mg_number": [50, 50, 50.0],
87
+ "dep_km": [1.0, 5.0, 20.0]})
88
+ t = cm.training_table(df)
89
+ assert list(t.label) == [1, 0]
90
+
91
+
92
+ def test_geology_multiplier():
93
+ assert list(geo.multiplier(np.array([0, 1, 2, 3, 4], "uint8"))) == pytest.approx([1.0, 0.3, 0.6, 1.0, 1.0])