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.
- riftprospect-0.1.0/AUTHORS.json +23 -0
- riftprospect-0.1.0/LICENSE +21 -0
- riftprospect-0.1.0/LICENSE-DATA.md +14 -0
- riftprospect-0.1.0/PKG-INFO +119 -0
- riftprospect-0.1.0/README.md +88 -0
- riftprospect-0.1.0/pyproject.toml +29 -0
- riftprospect-0.1.0/setup.cfg +4 -0
- riftprospect-0.1.0/src/riftprospect/__init__.py +2 -0
- riftprospect-0.1.0/src/riftprospect/chemmodel.py +105 -0
- riftprospect-0.1.0/src/riftprospect/cli.py +35 -0
- riftprospect-0.1.0/src/riftprospect/combine.py +69 -0
- riftprospect-0.1.0/src/riftprospect/fertility.py +82 -0
- riftprospect-0.1.0/src/riftprospect/geology.py +53 -0
- riftprospect-0.1.0/src/riftprospect/geophys.py +72 -0
- riftprospect-0.1.0/src/riftprospect/grid.py +94 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/PKG-INFO +119 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/SOURCES.txt +20 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/dependency_links.txt +1 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/entry_points.txt +2 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/requires.txt +13 -0
- riftprospect-0.1.0/src/riftprospect.egg-info/top_level.txt +1 -0
- riftprospect-0.1.0/tests/test_core.py +93 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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])
|