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.
Files changed (70) hide show
  1. wildlocate-0.1.0/PKG-INFO +16 -0
  2. wildlocate-0.1.0/README.md +63 -0
  3. wildlocate-0.1.0/pyproject.toml +41 -0
  4. wildlocate-0.1.0/setup.cfg +4 -0
  5. wildlocate-0.1.0/wildlocate/__init__.py +3 -0
  6. wildlocate-0.1.0/wildlocate/__main__.py +3 -0
  7. wildlocate-0.1.0/wildlocate/cli.py +91 -0
  8. wildlocate-0.1.0/wildlocate/core/__init__.py +0 -0
  9. wildlocate-0.1.0/wildlocate/core/api.py +79 -0
  10. wildlocate-0.1.0/wildlocate/core/catalog.py +8 -0
  11. wildlocate-0.1.0/wildlocate/core/data/__init__.py +0 -0
  12. wildlocate-0.1.0/wildlocate/core/data/background.py +359 -0
  13. wildlocate-0.1.0/wildlocate/core/data/environment.py +56 -0
  14. wildlocate-0.1.0/wildlocate/core/data/inaturalist.py +289 -0
  15. wildlocate-0.1.0/wildlocate/core/data/massdep_hydrography/__init__.py +0 -0
  16. wildlocate-0.1.0/wildlocate/core/data/massdep_hydrography/download.py +40 -0
  17. wildlocate-0.1.0/wildlocate/core/data/massdot_roads/__init__.py +0 -0
  18. wildlocate-0.1.0/wildlocate/core/data/massdot_roads/download.py +40 -0
  19. wildlocate-0.1.0/wildlocate/core/data/nlcd/__init__.py +0 -0
  20. wildlocate-0.1.0/wildlocate/core/data/nlcd/download.py +313 -0
  21. wildlocate-0.1.0/wildlocate/core/data/usgs_3dep/__init__.py +0 -0
  22. wildlocate-0.1.0/wildlocate/core/data/usgs_3dep/download.py +65 -0
  23. wildlocate-0.1.0/wildlocate/core/features/__init__.py +0 -0
  24. wildlocate-0.1.0/wildlocate/core/features/build_species_dataset.py +169 -0
  25. wildlocate-0.1.0/wildlocate/core/features/extract.py +326 -0
  26. wildlocate-0.1.0/wildlocate/core/features/terrain_feature_extraction.py +64 -0
  27. wildlocate-0.1.0/wildlocate/core/models/__init__.py +0 -0
  28. wildlocate-0.1.0/wildlocate/core/models/train_species.py +478 -0
  29. wildlocate-0.1.0/wildlocate/core/predict.py +219 -0
  30. wildlocate-0.1.0/wildlocate/core/prediction_worker.py +25 -0
  31. wildlocate-0.1.0/wildlocate/core/registry.py +169 -0
  32. wildlocate-0.1.0/wildlocate/core/service.py +70 -0
  33. wildlocate-0.1.0/wildlocate/core/training.py +150 -0
  34. wildlocate-0.1.0/wildlocate/core/training_worker.py +60 -0
  35. wildlocate-0.1.0/wildlocate/data/processed/features/species/bobcat_features.csv +1621 -0
  36. wildlocate-0.1.0/wildlocate/data/processed/features/species/coyote_features.csv +7761 -0
  37. wildlocate-0.1.0/wildlocate/data/processed/features/species/fisher_features.csv +1913 -0
  38. wildlocate-0.1.0/wildlocate/data/processed/features/species/north_american_river_otter_features.csv +2137 -0
  39. wildlocate-0.1.0/wildlocate/data/processed/features/species/red_fox_features.csv +6021 -0
  40. wildlocate-0.1.0/wildlocate/data/processed/models/bobcat.joblib +0 -0
  41. wildlocate-0.1.0/wildlocate/data/processed/models/bobcat_metrics.json +282 -0
  42. wildlocate-0.1.0/wildlocate/data/processed/models/coyote.joblib +0 -0
  43. wildlocate-0.1.0/wildlocate/data/processed/models/coyote_metrics.json +287 -0
  44. wildlocate-0.1.0/wildlocate/data/processed/models/fisher.joblib +0 -0
  45. wildlocate-0.1.0/wildlocate/data/processed/models/fisher_metrics.json +282 -0
  46. wildlocate-0.1.0/wildlocate/data/processed/models/north_american_river_otter.joblib +0 -0
  47. wildlocate-0.1.0/wildlocate/data/processed/models/north_american_river_otter_metrics.json +282 -0
  48. wildlocate-0.1.0/wildlocate/data/processed/models/red_fox.joblib +0 -0
  49. wildlocate-0.1.0/wildlocate/data/processed/models/red_fox_metrics.json +287 -0
  50. wildlocate-0.1.0/wildlocate/gui/__init__.py +0 -0
  51. wildlocate-0.1.0/wildlocate/gui/app.py +565 -0
  52. wildlocate-0.1.0/wildlocate/gui/client.py +89 -0
  53. wildlocate-0.1.0/wildlocate/gui/formatting.py +44 -0
  54. wildlocate-0.1.0/wildlocate/gui/location_map.py +134 -0
  55. wildlocate-0.1.0/wildlocate/gui/map/index.html +18 -0
  56. wildlocate-0.1.0/wildlocate/gui/map/map.css +13 -0
  57. wildlocate-0.1.0/wildlocate/gui/map/map.js +58 -0
  58. wildlocate-0.1.0/wildlocate/gui/map/vendor/LICENSE +26 -0
  59. wildlocate-0.1.0/wildlocate/gui/map/vendor/leaflet.css +661 -0
  60. wildlocate-0.1.0/wildlocate/gui/map/vendor/leaflet.js +6 -0
  61. wildlocate-0.1.0/wildlocate/gui/species_manager.py +354 -0
  62. wildlocate-0.1.0/wildlocate/gui/theme.py +62 -0
  63. wildlocate-0.1.0/wildlocate/gui/training_client.py +100 -0
  64. wildlocate-0.1.0/wildlocate/gui/widgets.py +117 -0
  65. wildlocate-0.1.0/wildlocate.egg-info/PKG-INFO +16 -0
  66. wildlocate-0.1.0/wildlocate.egg-info/SOURCES.txt +68 -0
  67. wildlocate-0.1.0/wildlocate.egg-info/dependency_links.txt +1 -0
  68. wildlocate-0.1.0/wildlocate.egg-info/entry_points.txt +2 -0
  69. wildlocate-0.1.0/wildlocate.egg-info/requires.txt +12 -0
  70. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Wild-Locate: desktop habitat suitability explorer for Massachusetts wildlife."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from wildlocate.cli import main
2
+
3
+ main()
@@ -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)
@@ -0,0 +1,8 @@
1
+ # Bundled models. Runtime species availability comes from core.registry.
2
+ SUPPORTED_SPECIES = (
3
+ "Bobcat",
4
+ "Fisher",
5
+ "North American River Otter",
6
+ "Red Fox",
7
+ "Coyote",
8
+ )
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()