wildlocate 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.
- wildlocate-0.1.0/PKG-INFO +16 -0
- wildlocate-0.1.0/README.md +63 -0
- wildlocate-0.1.0/pyproject.toml +41 -0
- wildlocate-0.1.0/setup.cfg +4 -0
- wildlocate-0.1.0/wildlocate/__init__.py +3 -0
- wildlocate-0.1.0/wildlocate/__main__.py +3 -0
- wildlocate-0.1.0/wildlocate/cli.py +91 -0
- wildlocate-0.1.0/wildlocate/core/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/api.py +79 -0
- wildlocate-0.1.0/wildlocate/core/catalog.py +8 -0
- wildlocate-0.1.0/wildlocate/core/data/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/data/background.py +359 -0
- wildlocate-0.1.0/wildlocate/core/data/environment.py +56 -0
- wildlocate-0.1.0/wildlocate/core/data/inaturalist.py +289 -0
- wildlocate-0.1.0/wildlocate/core/data/massdep_hydrography/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/data/massdep_hydrography/download.py +40 -0
- wildlocate-0.1.0/wildlocate/core/data/massdot_roads/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/data/massdot_roads/download.py +40 -0
- wildlocate-0.1.0/wildlocate/core/data/nlcd/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/data/nlcd/download.py +313 -0
- wildlocate-0.1.0/wildlocate/core/data/usgs_3dep/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/data/usgs_3dep/download.py +65 -0
- wildlocate-0.1.0/wildlocate/core/features/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/features/build_species_dataset.py +169 -0
- wildlocate-0.1.0/wildlocate/core/features/extract.py +326 -0
- wildlocate-0.1.0/wildlocate/core/features/terrain_feature_extraction.py +64 -0
- wildlocate-0.1.0/wildlocate/core/models/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/core/models/train_species.py +478 -0
- wildlocate-0.1.0/wildlocate/core/predict.py +219 -0
- wildlocate-0.1.0/wildlocate/core/prediction_worker.py +25 -0
- wildlocate-0.1.0/wildlocate/core/registry.py +169 -0
- wildlocate-0.1.0/wildlocate/core/service.py +70 -0
- wildlocate-0.1.0/wildlocate/core/training.py +150 -0
- wildlocate-0.1.0/wildlocate/core/training_worker.py +60 -0
- wildlocate-0.1.0/wildlocate/data/processed/features/species/bobcat_features.csv +1621 -0
- wildlocate-0.1.0/wildlocate/data/processed/features/species/coyote_features.csv +7761 -0
- wildlocate-0.1.0/wildlocate/data/processed/features/species/fisher_features.csv +1913 -0
- wildlocate-0.1.0/wildlocate/data/processed/features/species/north_american_river_otter_features.csv +2137 -0
- wildlocate-0.1.0/wildlocate/data/processed/features/species/red_fox_features.csv +6021 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/bobcat.joblib +0 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/bobcat_metrics.json +282 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/coyote.joblib +0 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/coyote_metrics.json +287 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/fisher.joblib +0 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/fisher_metrics.json +282 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/north_american_river_otter.joblib +0 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/north_american_river_otter_metrics.json +282 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/red_fox.joblib +0 -0
- wildlocate-0.1.0/wildlocate/data/processed/models/red_fox_metrics.json +287 -0
- wildlocate-0.1.0/wildlocate/gui/__init__.py +0 -0
- wildlocate-0.1.0/wildlocate/gui/app.py +565 -0
- wildlocate-0.1.0/wildlocate/gui/client.py +89 -0
- wildlocate-0.1.0/wildlocate/gui/formatting.py +44 -0
- wildlocate-0.1.0/wildlocate/gui/location_map.py +134 -0
- wildlocate-0.1.0/wildlocate/gui/map/index.html +18 -0
- wildlocate-0.1.0/wildlocate/gui/map/map.css +13 -0
- wildlocate-0.1.0/wildlocate/gui/map/map.js +58 -0
- wildlocate-0.1.0/wildlocate/gui/map/vendor/LICENSE +26 -0
- wildlocate-0.1.0/wildlocate/gui/map/vendor/leaflet.css +661 -0
- wildlocate-0.1.0/wildlocate/gui/map/vendor/leaflet.js +6 -0
- wildlocate-0.1.0/wildlocate/gui/species_manager.py +354 -0
- wildlocate-0.1.0/wildlocate/gui/theme.py +62 -0
- wildlocate-0.1.0/wildlocate/gui/training_client.py +100 -0
- wildlocate-0.1.0/wildlocate/gui/widgets.py +117 -0
- wildlocate-0.1.0/wildlocate.egg-info/PKG-INFO +16 -0
- wildlocate-0.1.0/wildlocate.egg-info/SOURCES.txt +68 -0
- wildlocate-0.1.0/wildlocate.egg-info/dependency_links.txt +1 -0
- wildlocate-0.1.0/wildlocate.egg-info/entry_points.txt +2 -0
- wildlocate-0.1.0/wildlocate.egg-info/requires.txt +12 -0
- wildlocate-0.1.0/wildlocate.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wildlocate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.10
|
|
5
|
+
Requires-Dist: geopandas
|
|
6
|
+
Requires-Dist: joblib
|
|
7
|
+
Requires-Dist: numpy
|
|
8
|
+
Requires-Dist: pandas
|
|
9
|
+
Requires-Dist: platformdirs
|
|
10
|
+
Requires-Dist: pyproj
|
|
11
|
+
Requires-Dist: PyQt6
|
|
12
|
+
Requires-Dist: PyQt6-WebEngine
|
|
13
|
+
Requires-Dist: rasterio
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Requires-Dist: scikit-learn
|
|
16
|
+
Requires-Dist: shapely
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# WildLocate
|
|
2
|
+
|
|
3
|
+
# Installation From Source
|
|
4
|
+
|
|
5
|
+
Clone
|
|
6
|
+
|
|
7
|
+
```git clone https://github.com/cx-57/Wild-Locate```
|
|
8
|
+
|
|
9
|
+
Create Virtual Environment
|
|
10
|
+
|
|
11
|
+
```python -m venv .venv```
|
|
12
|
+
|
|
13
|
+
Install WildLocate (Installs Deps)
|
|
14
|
+
```pip install -e .```
|
|
15
|
+
|
|
16
|
+
Setup & Run
|
|
17
|
+
|
|
18
|
+
Download Datasets
|
|
19
|
+
```wildlocate init```
|
|
20
|
+
|
|
21
|
+
Launch
|
|
22
|
+
```wildlocate```
|
|
23
|
+
|
|
24
|
+
## Train a species in the desktop app
|
|
25
|
+
|
|
26
|
+
Open **Manage species → Train a new species**. Enter an exact common or scientific
|
|
27
|
+
name, select **Find species**, and confirm the matched mammal. The app checks local
|
|
28
|
+
environmental datasets and downloads research-grade Massachusetts observations
|
|
29
|
+
from iNaturalist. If environmental data is missing, use **Download environmental
|
|
30
|
+
data**, then check the species again.
|
|
31
|
+
|
|
32
|
+
After cleaning, at least 25 observations are required to attempt training; their
|
|
33
|
+
spatial distribution and the available background samples can still prevent valid
|
|
34
|
+
five-fold evaluation. Select **Start training** to prepare background locations,
|
|
35
|
+
extract habitat features, compare models and fit the selected model. Downloads
|
|
36
|
+
require internet access. Training runs in a separate process, reports progress and
|
|
37
|
+
can be cancelled.
|
|
38
|
+
|
|
39
|
+
Completed models appear under **Your models**. Review the observation counts and
|
|
40
|
+
spatial validation results, then select **Enable model** to add the species to the
|
|
41
|
+
analysis dropdown. Validation scores are not probabilities of wildlife presence
|
|
42
|
+
or a guarantee of ecological reliability. Retraining creates a separate model;
|
|
43
|
+
the current model stays enabled until a replacement is explicitly enabled.
|
|
44
|
+
Custom models can be deleted, and bundled models can be re-enabled at any time.
|
|
45
|
+
|
|
46
|
+
Custom models, training files and the background-observation cache are stored in
|
|
47
|
+
the per-user `wildlocate` application-data directory, not inside the installed
|
|
48
|
+
package. `WILDLOCATE_DATA_DIR` optionally overrides this directory. Completed
|
|
49
|
+
models must include their model, metadata and comparison dataset before they can
|
|
50
|
+
be enabled. The API and desktop dropdown use the same model registry.
|
|
51
|
+
|
|
52
|
+
This version supports training Massachusetts mammals using the existing habitat
|
|
53
|
+
features. Other regions and animal groups require additional data and modeling work.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
python -m unittest discover -s tests -v
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Tests use isolated storage under `artifacts/test-runs`, mock external downloads,
|
|
62
|
+
train and reload a real model, and exercise the desktop workflow and background
|
|
63
|
+
process cancellation. They do not require environmental downloads.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=70", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "wildlocate"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"geopandas",
|
|
12
|
+
"joblib",
|
|
13
|
+
"numpy",
|
|
14
|
+
"pandas",
|
|
15
|
+
"platformdirs",
|
|
16
|
+
"pyproj",
|
|
17
|
+
"PyQt6",
|
|
18
|
+
"PyQt6-WebEngine",
|
|
19
|
+
"rasterio",
|
|
20
|
+
"requests",
|
|
21
|
+
"scikit-learn",
|
|
22
|
+
"shapely",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
wildlocate = "wildlocate.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
include = ["wildlocate*"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
"wildlocate" = [
|
|
34
|
+
"gui/map/index.html",
|
|
35
|
+
"gui/map/map.css",
|
|
36
|
+
"gui/map/map.js",
|
|
37
|
+
"gui/map/vendor/*",
|
|
38
|
+
"data/processed/models/*.joblib",
|
|
39
|
+
"data/processed/models/*.json",
|
|
40
|
+
"data/processed/features/species/*.csv",
|
|
41
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def cmd_gui(args):
|
|
7
|
+
from wildlocate.gui.app import main as gui_main
|
|
8
|
+
gui_main()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_init(args):
|
|
12
|
+
from wildlocate.core.data.environment import get_user_data_dir
|
|
13
|
+
data_dir = get_user_data_dir()
|
|
14
|
+
raw_dir = data_dir / "raw"
|
|
15
|
+
|
|
16
|
+
print(f"Wild-Locate: downloading environmental datasets to {data_dir}")
|
|
17
|
+
print("This may take several minutes.\n")
|
|
18
|
+
|
|
19
|
+
from wildlocate.core.data.nlcd.download import download_nlcd
|
|
20
|
+
nlcd_dir = raw_dir / "nlcd"
|
|
21
|
+
print("Downloading NLCD land cover and impervious surface...")
|
|
22
|
+
download_nlcd(nlcd_dir, year=2025, tiles=4)
|
|
23
|
+
print()
|
|
24
|
+
|
|
25
|
+
from wildlocate.core.data.usgs_3dep.download import download_elevation
|
|
26
|
+
usgs_dir = raw_dir / "usgs_3dep"
|
|
27
|
+
print("Downloading USGS 3DEP elevation...")
|
|
28
|
+
download_elevation(
|
|
29
|
+
bbox=(-73.60, 41.10, -69.80, 42.95),
|
|
30
|
+
output_path=usgs_dir / "elevation_3dep.tif",
|
|
31
|
+
)
|
|
32
|
+
print()
|
|
33
|
+
|
|
34
|
+
from wildlocate.core.data.massdep_hydrography.download import download_hydrography
|
|
35
|
+
hydro_dir = raw_dir / "massdep_hydrography"
|
|
36
|
+
print("Downloading MassDEP hydrography...")
|
|
37
|
+
download_hydrography(hydro_dir / "massachusetts_hydrography.zip")
|
|
38
|
+
print()
|
|
39
|
+
|
|
40
|
+
from wildlocate.core.data.massdot_roads.download import download_roads
|
|
41
|
+
roads_dir = raw_dir / "massdot_roads"
|
|
42
|
+
print("Downloading MassDOT roads...")
|
|
43
|
+
download_roads(roads_dir / "massachusetts_roads.zip")
|
|
44
|
+
print()
|
|
45
|
+
|
|
46
|
+
print("All datasets downloaded. Run 'wildlocate' to launch the app.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_status(args):
|
|
50
|
+
from wildlocate.core.data.environment import DatasetPaths
|
|
51
|
+
paths = DatasetPaths()
|
|
52
|
+
datasets = {
|
|
53
|
+
"NLCD land cover": paths.nlcd_landcover,
|
|
54
|
+
"NLCD impervious surface": paths.nlcd_impervious,
|
|
55
|
+
"USGS 3DEP elevation": paths.usgs_3dep_elevation,
|
|
56
|
+
"MassDEP hydrography (poly)": paths.massdep_hydrography_poly,
|
|
57
|
+
"MassDEP hydrography (arc)": paths.massdep_hydrography_arc,
|
|
58
|
+
"MassDOT roads": paths.massdot_roads,
|
|
59
|
+
}
|
|
60
|
+
all_ok = True
|
|
61
|
+
for name, path in datasets.items():
|
|
62
|
+
status = "OK" if path.exists() else "MISSING"
|
|
63
|
+
if not path.exists():
|
|
64
|
+
all_ok = False
|
|
65
|
+
print(f" [{status}] {name}")
|
|
66
|
+
print(f" {path}")
|
|
67
|
+
print()
|
|
68
|
+
if all_ok:
|
|
69
|
+
print("All datasets are available.")
|
|
70
|
+
else:
|
|
71
|
+
print("Some datasets are missing. Run 'wildlocate init' to download them.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
parser = argparse.ArgumentParser(
|
|
76
|
+
prog="wildlocate",
|
|
77
|
+
description="Wild-Locate habitat explorer for Massachusetts wildlife",
|
|
78
|
+
)
|
|
79
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
80
|
+
subparsers.add_parser("gui", help="Launch the GUI (default)")
|
|
81
|
+
subparsers.add_parser("init", help="Download required environmental datasets")
|
|
82
|
+
subparsers.add_parser("status", help="Check dataset availability")
|
|
83
|
+
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
|
|
86
|
+
if args.command in (None, "gui"):
|
|
87
|
+
cmd_gui(args)
|
|
88
|
+
elif args.command == "init":
|
|
89
|
+
cmd_init(args)
|
|
90
|
+
elif args.command == "status":
|
|
91
|
+
cmd_status(args)
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from fastapi import Body, FastAPI
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from pydantic import ConfigDict, Field, ValidationError, create_model
|
|
7
|
+
|
|
8
|
+
from wildlocate.core.registry import available_species
|
|
9
|
+
from wildlocate.core.service import PredictionError, assess_habitat
|
|
10
|
+
|
|
11
|
+
app = FastAPI(
|
|
12
|
+
title="Wild-Locate",
|
|
13
|
+
description="Relative habitat suitability for wildlife. Scores are not probabilities of species presence.",
|
|
14
|
+
version="1.0.0",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PredictionRequest = create_model(
|
|
19
|
+
"PredictionRequest",
|
|
20
|
+
__config__=ConfigDict(extra="forbid", str_strip_whitespace=True),
|
|
21
|
+
species=(str, Field(min_length=1, max_length=100)),
|
|
22
|
+
latitude=(float, Field(ge=-90, le=90, allow_inf_nan=False, strict=True)),
|
|
23
|
+
longitude=(float, Field(ge=-180, le=180, allow_inf_nan=False, strict=True)),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SuitabilityCategory = Enum("SuitabilityCategory", {
|
|
27
|
+
"VERY_LOW": "Very Low", "LOW": "Low", "MODERATE": "Moderate",
|
|
28
|
+
"HIGH": "High", "VERY_HIGH": "Very High",
|
|
29
|
+
}, type=str)
|
|
30
|
+
|
|
31
|
+
PredictionResponse = create_model(
|
|
32
|
+
"PredictionResponse",
|
|
33
|
+
species=(str, ...),
|
|
34
|
+
latitude=(float, ...),
|
|
35
|
+
longitude=(float, ...),
|
|
36
|
+
score=(float, Field(allow_inf_nan=False)),
|
|
37
|
+
percentile=(int, Field(ge=0, le=100)),
|
|
38
|
+
category=(SuitabilityCategory, ...),
|
|
39
|
+
model=(str, ...),
|
|
40
|
+
training_observations=(int, ...),
|
|
41
|
+
features=(dict[str, float], ...),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.exception_handler(PredictionError)
|
|
46
|
+
async def prediction_error_handler(request, exc):
|
|
47
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": str(exc), "code": exc.code})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.exception_handler(RequestValidationError)
|
|
51
|
+
async def validation_error_handler(request, exc):
|
|
52
|
+
fields = sorted({str(error["loc"][-1]) for error in exc.errors()})
|
|
53
|
+
return JSONResponse(status_code=422, content={
|
|
54
|
+
"detail": "Check the request: supply a species, latitude from −90 to 90, and longitude from −180 to 180 as numbers.",
|
|
55
|
+
"code": "invalid_request", "fields": fields,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/species")
|
|
60
|
+
def species():
|
|
61
|
+
return {"species": list(available_species())}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.get("/health")
|
|
65
|
+
def health():
|
|
66
|
+
return {"status": "ok"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.post("/predict", response_model=PredictionResponse, openapi_extra={
|
|
70
|
+
"requestBody": {"content": {"application/json": {"schema": PredictionRequest.model_json_schema()}}},
|
|
71
|
+
})
|
|
72
|
+
def predict(request=Body(...)):
|
|
73
|
+
try:
|
|
74
|
+
request = PredictionRequest.model_validate(request)
|
|
75
|
+
except ValidationError as exc:
|
|
76
|
+
raise RequestValidationError([
|
|
77
|
+
{**error, "loc": ("body", *error["loc"])} for error in exc.errors()
|
|
78
|
+
]) from exc
|
|
79
|
+
return assess_habitat(request.species, request.latitude, request.longitude)
|
|
File without changes
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import requests
|
|
12
|
+
from pyproj import Transformer
|
|
13
|
+
|
|
14
|
+
from wildlocate.core.data.inaturalist import resolve_species
|
|
15
|
+
|
|
16
|
+
API_BASE = "https://api.inaturalist.org/v1"
|
|
17
|
+
MAMMAL_POOL_FILE = Path("data/processed/samples/massachusetts_mammal_pool.csv")
|
|
18
|
+
DEFAULT_MAX_MAMMAL_POOL = 50000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def species_slug(species_name):
|
|
22
|
+
slug = re.sub(r"[^a-z0-9]+", "_", species_name.strip().lower())
|
|
23
|
+
slug = slug.strip("_")
|
|
24
|
+
if not slug:
|
|
25
|
+
raise ValueError("Species name is required.")
|
|
26
|
+
return slug
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def api_get(endpoint, params=None):
|
|
30
|
+
response = requests.get(f"{API_BASE}{endpoint}", params=params, timeout=60)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
data = response.json()
|
|
33
|
+
if "error" in data:
|
|
34
|
+
raise RuntimeError(data["error"])
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_place_id(place_name):
|
|
39
|
+
place_name = place_name.strip()
|
|
40
|
+
if not place_name:
|
|
41
|
+
raise ValueError("Place name is required.")
|
|
42
|
+
|
|
43
|
+
data = api_get("/places/autocomplete", {"q": place_name, "per_page": 20})
|
|
44
|
+
results = data.get("results", [])
|
|
45
|
+
if not results:
|
|
46
|
+
raise RuntimeError(f"Could not find place: {place_name}")
|
|
47
|
+
return int(results[0]["id"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def download_mammal_pool(place_name="Massachusetts", progress=None):
|
|
51
|
+
mammals = resolve_species("Mammalia")
|
|
52
|
+
place_id = find_place_id(place_name)
|
|
53
|
+
|
|
54
|
+
rows = []
|
|
55
|
+
id_above = 0
|
|
56
|
+
total_rows = 0
|
|
57
|
+
|
|
58
|
+
while total_rows < DEFAULT_MAX_MAMMAL_POOL:
|
|
59
|
+
params = {
|
|
60
|
+
"taxon_id": mammals["taxon_id"],
|
|
61
|
+
"place_id": place_id,
|
|
62
|
+
"quality_grade": "research",
|
|
63
|
+
"verifiable": "true",
|
|
64
|
+
"captive": "false",
|
|
65
|
+
"per_page": 200,
|
|
66
|
+
"order_by": "id",
|
|
67
|
+
"order": "asc",
|
|
68
|
+
"id_above": id_above,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
data = api_get("/observations", params)
|
|
72
|
+
batch = data.get("results", [])
|
|
73
|
+
if not batch:
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
for obs in batch:
|
|
77
|
+
geojson = obs.get("geojson") or {}
|
|
78
|
+
coords = geojson.get("coordinates")
|
|
79
|
+
if not coords or len(coords) < 2:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
longitude, latitude = coords[:2]
|
|
83
|
+
try:
|
|
84
|
+
latitude = float(latitude)
|
|
85
|
+
longitude = float(longitude)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
taxon = obs.get("taxon") or {}
|
|
93
|
+
rows.append(
|
|
94
|
+
{
|
|
95
|
+
"observation_id": obs.get("id"),
|
|
96
|
+
"taxon_id": taxon.get("id"),
|
|
97
|
+
"taxon_name": taxon.get("scientific_name") or taxon.get("name"),
|
|
98
|
+
"common_name": taxon.get("preferred_common_name") or taxon.get("common_name"),
|
|
99
|
+
"latitude": latitude,
|
|
100
|
+
"longitude": longitude,
|
|
101
|
+
"positional_accuracy": obs.get("positional_accuracy"),
|
|
102
|
+
"observed_on": obs.get("observed_on"),
|
|
103
|
+
"coordinates_obscured": bool(obs.get("obscured", False)),
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
total_rows += 1
|
|
108
|
+
if total_rows >= DEFAULT_MAX_MAMMAL_POOL:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if len(batch) < params["per_page"]:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
id_above = batch[-1]["id"]
|
|
115
|
+
if progress:
|
|
116
|
+
progress(f"Downloaded {total_rows:,} Massachusetts background observations…")
|
|
117
|
+
|
|
118
|
+
if not rows:
|
|
119
|
+
raise RuntimeError("No usable Massachusetts Mammalia observations were found.")
|
|
120
|
+
|
|
121
|
+
df = pd.DataFrame(rows)
|
|
122
|
+
df["observation_id"] = pd.to_numeric(df["observation_id"], errors="coerce")
|
|
123
|
+
df["taxon_id"] = pd.to_numeric(df["taxon_id"], errors="coerce")
|
|
124
|
+
df["positional_accuracy"] = pd.to_numeric(df["positional_accuracy"], errors="coerce")
|
|
125
|
+
|
|
126
|
+
return df
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_or_create_mammal_pool(refresh_pool=False, pool_file=None, progress=None):
|
|
130
|
+
pool_file = Path(pool_file) if pool_file is not None else MAMMAL_POOL_FILE
|
|
131
|
+
if pool_file.exists() and not refresh_pool:
|
|
132
|
+
pool_df = pd.read_csv(pool_file)
|
|
133
|
+
required_columns = {
|
|
134
|
+
"observation_id", "taxon_id", "taxon_name", "common_name",
|
|
135
|
+
"latitude", "longitude", "positional_accuracy", "observed_on",
|
|
136
|
+
"coordinates_obscured",
|
|
137
|
+
}
|
|
138
|
+
missing = required_columns.difference(pool_df.columns)
|
|
139
|
+
if missing:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Existing mammal pool at {pool_file} is missing required columns: {sorted(missing)}"
|
|
142
|
+
)
|
|
143
|
+
return pool_df
|
|
144
|
+
|
|
145
|
+
pool_df = download_mammal_pool(progress=progress)
|
|
146
|
+
pool_file.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
pool_df.to_csv(pool_file, index=False)
|
|
148
|
+
return pool_df
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def project_to_5070(df):
|
|
152
|
+
transformer = Transformer.from_crs("EPSG:4326", "EPSG:5070", always_xy=True)
|
|
153
|
+
x_5070, y_5070 = transformer.transform(df["longitude"].to_numpy(), df["latitude"].to_numpy())
|
|
154
|
+
|
|
155
|
+
projected = df.copy()
|
|
156
|
+
projected["x_5070"] = x_5070
|
|
157
|
+
projected["y_5070"] = y_5070
|
|
158
|
+
return projected
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def filter_mammal_pool(pool_df, target_taxon_id):
|
|
162
|
+
if pool_df is None or pool_df.empty:
|
|
163
|
+
return pool_df
|
|
164
|
+
|
|
165
|
+
filtered = pool_df.copy()
|
|
166
|
+
|
|
167
|
+
filtered = filtered.dropna(subset=["latitude", "longitude"]).copy()
|
|
168
|
+
filtered["latitude"] = pd.to_numeric(filtered["latitude"], errors="coerce")
|
|
169
|
+
filtered["longitude"] = pd.to_numeric(filtered["longitude"], errors="coerce")
|
|
170
|
+
filtered = filtered.dropna(subset=["latitude", "longitude"]).copy()
|
|
171
|
+
|
|
172
|
+
filtered["coordinates_obscured"] = (
|
|
173
|
+
filtered["coordinates_obscured"].fillna(False).astype(str).str.lower() == "true"
|
|
174
|
+
)
|
|
175
|
+
filtered = filtered[~filtered["coordinates_obscured"]].copy()
|
|
176
|
+
|
|
177
|
+
filtered["positional_accuracy"] = pd.to_numeric(filtered["positional_accuracy"], errors="coerce")
|
|
178
|
+
filtered = filtered[
|
|
179
|
+
filtered["positional_accuracy"].isna() | (filtered["positional_accuracy"] <= 250)
|
|
180
|
+
].copy()
|
|
181
|
+
|
|
182
|
+
filtered = filtered.drop_duplicates(subset=["observation_id"]).copy()
|
|
183
|
+
filtered = filtered.drop_duplicates(subset=["latitude", "longitude"]).copy()
|
|
184
|
+
|
|
185
|
+
if target_taxon_id is not None:
|
|
186
|
+
filtered = filtered[filtered["taxon_id"] != target_taxon_id].copy()
|
|
187
|
+
|
|
188
|
+
return filtered
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def compute_min_distance_to_presence(candidate_x, candidate_y, presence_x, presence_y):
|
|
192
|
+
distances = np.full(candidate_x.shape[0], np.inf, dtype=float)
|
|
193
|
+
|
|
194
|
+
for idx, (cx, cy) in enumerate(zip(candidate_x, candidate_y)):
|
|
195
|
+
diff_x = presence_x - cx
|
|
196
|
+
diff_y = presence_y - cy
|
|
197
|
+
distances[idx] = np.sqrt(np.min(diff_x**2 + diff_y**2))
|
|
198
|
+
|
|
199
|
+
return distances
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def spatial_thin(df, thinning_distance_m, random_state):
|
|
203
|
+
if df.empty:
|
|
204
|
+
return df
|
|
205
|
+
|
|
206
|
+
projected = project_to_5070(df)
|
|
207
|
+
projected["grid_x"] = np.floor(projected["x_5070"] / thinning_distance_m).astype(int)
|
|
208
|
+
projected["grid_y"] = np.floor(projected["y_5070"] / thinning_distance_m).astype(int)
|
|
209
|
+
|
|
210
|
+
rng = np.random.default_rng(random_state)
|
|
211
|
+
order = rng.permutation(len(projected))
|
|
212
|
+
kept = []
|
|
213
|
+
seen_cells = set()
|
|
214
|
+
|
|
215
|
+
for idx in order:
|
|
216
|
+
row = projected.iloc[idx]
|
|
217
|
+
cell = (int(row["grid_x"]), int(row["grid_y"]))
|
|
218
|
+
if cell in seen_cells:
|
|
219
|
+
continue
|
|
220
|
+
seen_cells.add(cell)
|
|
221
|
+
kept.append(idx)
|
|
222
|
+
|
|
223
|
+
return projected.iloc[kept].reset_index(drop=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def generate_background(
|
|
227
|
+
species_name,
|
|
228
|
+
background_ratio=3.0,
|
|
229
|
+
exclusion_distance_m=1000,
|
|
230
|
+
thinning_distance_m=500,
|
|
231
|
+
random_state=42,
|
|
232
|
+
*, samples_dir="data/processed/samples", pool_file=None, taxon_id=None, progress=None,
|
|
233
|
+
):
|
|
234
|
+
if background_ratio <= 0:
|
|
235
|
+
raise ValueError("background_ratio must be greater than 0.")
|
|
236
|
+
if exclusion_distance_m < 0:
|
|
237
|
+
raise ValueError("exclusion_distance_m must be non-negative.")
|
|
238
|
+
if thinning_distance_m <= 0:
|
|
239
|
+
raise ValueError("thinning_distance_m must be greater than 0.")
|
|
240
|
+
|
|
241
|
+
target_taxon_id = int(taxon_id if taxon_id is not None else resolve_species(species_name)["taxon_id"])
|
|
242
|
+
|
|
243
|
+
species_slug_value = species_slug(species_name)
|
|
244
|
+
samples_dir = Path(samples_dir)
|
|
245
|
+
presence_file = samples_dir / f"{species_slug_value}_occurrences.csv"
|
|
246
|
+
if not presence_file.exists():
|
|
247
|
+
raise FileNotFoundError(
|
|
248
|
+
f"Could not find cleaned presence file at {presence_file}. Run the Phase 3 species download first."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
presence_df = pd.read_csv(presence_file)
|
|
252
|
+
presence_df = presence_df.dropna(subset=["latitude", "longitude"]).copy()
|
|
253
|
+
presence_df["latitude"] = pd.to_numeric(presence_df["latitude"], errors="coerce")
|
|
254
|
+
presence_df["longitude"] = pd.to_numeric(presence_df["longitude"], errors="coerce")
|
|
255
|
+
presence_df = presence_df.dropna(subset=["latitude", "longitude"]).copy()
|
|
256
|
+
|
|
257
|
+
if presence_df.empty:
|
|
258
|
+
raise RuntimeError(f"No usable presence points found in {presence_file}.")
|
|
259
|
+
|
|
260
|
+
pool_df = load_or_create_mammal_pool(pool_file=pool_file, progress=progress)
|
|
261
|
+
candidate_df = filter_mammal_pool(pool_df, target_taxon_id)
|
|
262
|
+
|
|
263
|
+
if candidate_df.empty:
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"No usable mammal-pool candidates remain after quality filtering for {species_name}."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
projected_presence = project_to_5070(presence_df)
|
|
269
|
+
projected_candidates = project_to_5070(candidate_df)
|
|
270
|
+
|
|
271
|
+
presence_x = projected_presence["x_5070"].to_numpy(dtype=float)
|
|
272
|
+
presence_y = projected_presence["y_5070"].to_numpy(dtype=float)
|
|
273
|
+
candidate_x = projected_candidates["x_5070"].to_numpy(dtype=float)
|
|
274
|
+
candidate_y = projected_candidates["y_5070"].to_numpy(dtype=float)
|
|
275
|
+
|
|
276
|
+
min_distances = compute_min_distance_to_presence(candidate_x, candidate_y, presence_x, presence_y)
|
|
277
|
+
projected_candidates["distance_to_presence_m"] = min_distances
|
|
278
|
+
|
|
279
|
+
projected_candidates = projected_candidates[
|
|
280
|
+
projected_candidates["distance_to_presence_m"] >= exclusion_distance_m
|
|
281
|
+
].copy()
|
|
282
|
+
|
|
283
|
+
if projected_candidates.empty:
|
|
284
|
+
raise RuntimeError(
|
|
285
|
+
f"No candidate background points remain after excluding all locations within {exclusion_distance_m} m of known {species_name} presences."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
thinned_candidates = spatial_thin(
|
|
289
|
+
projected_candidates[["latitude", "longitude", "observation_id", "taxon_id", "taxon_name", "common_name"]].copy(),
|
|
290
|
+
thinning_distance_m,
|
|
291
|
+
random_state,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
required_background = int(round(len(presence_df) * background_ratio))
|
|
295
|
+
|
|
296
|
+
if len(thinned_candidates) < required_background:
|
|
297
|
+
raise RuntimeError(
|
|
298
|
+
f"Too few candidate background points remain after filtering and thinning: {len(thinned_candidates)} available, {required_background} required."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
rng = np.random.default_rng(random_state)
|
|
302
|
+
sample_indices = rng.choice(len(thinned_candidates), size=required_background, replace=False)
|
|
303
|
+
sample_df = thinned_candidates.iloc[sample_indices].reset_index(drop=True)
|
|
304
|
+
|
|
305
|
+
sample_df = sample_df[["latitude", "longitude"]].copy()
|
|
306
|
+
sample_df["presence"] = 0
|
|
307
|
+
|
|
308
|
+
background_file = samples_dir / f"{species_slug_value}_background.csv"
|
|
309
|
+
background_file.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
sample_df.to_csv(background_file, index=False)
|
|
311
|
+
|
|
312
|
+
training_df = pd.concat(
|
|
313
|
+
[
|
|
314
|
+
presence_df[["latitude", "longitude"]].copy().assign(presence=1),
|
|
315
|
+
sample_df[["latitude", "longitude", "presence"]].copy(),
|
|
316
|
+
],
|
|
317
|
+
ignore_index=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
training_df = training_df.sample(frac=1, random_state=random_state).reset_index(drop=True)
|
|
321
|
+
|
|
322
|
+
training_file = samples_dir / f"{species_slug_value}_training_points.csv"
|
|
323
|
+
training_file.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
training_df.to_csv(training_file, index=False)
|
|
325
|
+
|
|
326
|
+
return sample_df
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main():
|
|
330
|
+
parser = argparse.ArgumentParser(
|
|
331
|
+
description="Generate target-group background points for a species in Massachusetts.",
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument("--species", required=True, help="Species name, e.g. 'Fisher'.")
|
|
334
|
+
parser.add_argument("--background-ratio", type=float, default=3.0)
|
|
335
|
+
parser.add_argument("--exclusion-distance-m", type=float, default=1000)
|
|
336
|
+
parser.add_argument("--thinning-distance-m", type=float, default=500)
|
|
337
|
+
parser.add_argument("--refresh-pool", action="store_true")
|
|
338
|
+
args = parser.parse_args()
|
|
339
|
+
|
|
340
|
+
load_or_create_mammal_pool(refresh_pool=args.refresh_pool)
|
|
341
|
+
|
|
342
|
+
background_df = generate_background(
|
|
343
|
+
args.species,
|
|
344
|
+
background_ratio=args.background_ratio,
|
|
345
|
+
exclusion_distance_m=args.exclusion_distance_m,
|
|
346
|
+
thinning_distance_m=args.thinning_distance_m,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
species_slug_value = species_slug(args.species)
|
|
350
|
+
background_file = Path(f"data/processed/samples/{species_slug_value}_background.csv")
|
|
351
|
+
training_file = Path(f"data/processed/samples/{species_slug_value}_training_points.csv")
|
|
352
|
+
|
|
353
|
+
print(f"Background points saved to: {background_file}")
|
|
354
|
+
print(f"Training points saved to: {training_file}")
|
|
355
|
+
print(f"Background rows: {len(background_df)}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|