benjamintia commited on
Commit
81f7f5d
·
verified ·
1 Parent(s): 37263b7

Upload folder using huggingface_hub

Browse files
Files changed (11) hide show
  1. .dockerignore +6 -0
  2. .gitignore +8 -0
  3. Dockerfile +19 -0
  4. LICENSE +21 -0
  5. README.md +268 -7
  6. api_client.py +233 -0
  7. app.py +743 -0
  8. data_processor.py +218 -0
  9. docker-compose.yml +6 -0
  10. requirements.txt +7 -0
  11. visualizations.py +218 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .git
4
+ .env
5
+ *.egg-info
6
+ .DS_Store
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .DS_Store
5
+ .venv/
6
+ venv/
7
+ .env
8
+ *.log
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies needed by Folium/Streamlit
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ curl \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ EXPOSE 7860
16
+
17
+ HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health || exit 1
18
+
19
+ ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Tia
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.
README.md CHANGED
@@ -1,10 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: PaleoData Explorer
3
- emoji: 🐢
4
- colorFrom: yellow
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <h1 align="center">🦴 PaleoData Explorer</h1>
2
+
3
+ <p align="center">
4
+ <strong>A professional-grade Streamlit dashboard for querying, cleaning, visualising,<br>and exporting fossil occurrence data from the Paleobiology Database.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="#features">Features</a> •
9
+ <a href="#quick-start">Quick Start</a> •
10
+ <a href="#architecture">Architecture</a> •
11
+ <a href="#usage-guide">Usage Guide</a> •
12
+ <a href="#data-sources">Data Sources</a> •
13
+ <a href="#license">License</a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Overview
19
+
20
+ PaleoData Explorer turns the messy, raw JSON from the [Paleobiology Database (PBDB)](https://paleobiodb.org) API into an interactive dashboard with paleogeographic maps, deep-time timelines, and Wikipedia-powered taxon profiles. It is designed for **paleontologists**, **geology students**, and **science communicators** who need to explore fossil records without writing code.
21
+
22
+ ### What Problem Does It Solve?
23
+
24
+ The PBDB is the gold-standard repository for fossil occurrence data, but its API returns abbreviated field names, incomplete records, and no visualisation layer. Researchers typically spend hours writing one-off scripts just to see their data on a map.
25
+
26
+ PaleoData Explorer provides a **zero-code pipeline**:
27
+ - Query the PBDB by taxon name and geological time window
28
+ - Automatically clean and standardise the data
29
+ - Visualise results on paleocoordinate maps and stratigraphic range charts
30
+ - Browse Wikipedia summaries and images for any taxon
31
+ - Export a clean CSV for further analysis in R, Python, or Excel
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ ### 🔍 Intelligent PBDB Queries
38
+ - Search by any taxonomic rank — clade, family, genus, or species
39
+ - PBDB resolves parent clades hierarchically (e.g. "Tyrannosauridae" returns all subordinate taxa)
40
+ - Filter by geological time window (Ma) with a slider
41
+ - Configurable record limit (100–5000)
42
+
43
+ ### 🧹 Automated Data Cleaning
44
+ - Drops records missing temporal bounds (`max_ma` / `min_ma`) or paleocoordinates
45
+ - Imputes single-bound age estimates
46
+ - Computes `middle_age = (max_ma + min_ma) / 2` for point plotting
47
+ - Transparent pipeline statistics showing records dropped at each stage
48
+
49
+ ### 🗺️ Paleogeographic Map
50
+ - **MarkerCluster** rendering for smooth performance with thousands of points
51
+ - Plotted on **paleocoordinates** — showing where organisms lived millions of years ago, accounting for continental drift
52
+ - Click any marker to jump to that organism's Wikipedia profile
53
+ - Tooltips showing `matched_name`, geological interval, and age range
54
+
55
+ ### 📈 Deep-Time Timeline
56
+ - Horizontal stratigraphic range chart (Gantt-style)
57
+ - **X-axis reversed** — older on the left, younger on the right (geological convention)
58
+ - Top‑50 taxa by oldest age, sorted by midpoint
59
+
60
+ ### 🔍 Taxon Profile Viewer
61
+ - Select any taxon from the cleaned dataset via dropdown
62
+ - Fetches **Wikipedia summary** and **thumbnail image** via the Wikimedia REST API
63
+ - Results cached for 1 hour (no redundant API calls)
64
+ - Falls back from genus-level to species-level lookup
65
+
66
+ ### 📥 CSV Export
67
+ - One-click download of the fully cleaned DataFrame
68
+ - Ready for import into R, Python, Excel, or GIS tools
69
+
70
+ ### 📚 Built-in Taxon Reference
71
+ - 130+ pre-listed taxa across 7 categories (Dinosaurs, Marine Reptiles, Mammals, Invertebrates, Plants, etc.)
72
+ - **Clickable buttons** auto-fill the search bar — no typing needed
73
+ - Always visible below results
74
+
75
+ ---
76
+
77
+ ## Quick Start
78
+
79
+ ### Docker (Recommended)
80
+
81
+ ```bash
82
+ # Clone the repository
83
+ git clone https://github.com/BenjaminTia/PaleoPedia.git
84
+ cd PaleoPedia
85
+
86
+ # Build and start
87
+ docker compose up --build -d
88
+
89
+ # Open in browser
90
+ open http://localhost:8501
91
+ ```
92
+
93
+ ### Local Installation
94
+
95
+ ```bash
96
+ # Clone and install
97
+ git clone https://github.com/BenjaminTia/PaleoPedia.git
98
+ cd PaleoPedia
99
+ pip install -r requirements.txt
100
+
101
+ # Run
102
+ streamlit run app.py
103
+ ```
104
+
105
+ Open **http://localhost:8501** in your browser.
106
+
107
+ ---
108
+
109
+ ## Architecture
110
+
111
+ ```
112
+ PaleoPedia/
113
+ ├── app.py # Streamlit entry point — sidebar, tabs, export, UI
114
+ ├── api_client.py # PBDB & Wikipedia API wrappers with error handling
115
+ ├── data_processor.py # JSON → DataFrame, cleaning pipeline, statistics
116
+ ├── visualizations.py # Folium MarkerCluster map + Plotly timeline chart
117
+ ├── requirements.txt # Pinned Python dependencies
118
+ ├── Dockerfile # Python 3.11-slim, multi-stage-friendly
119
+ ├── docker-compose.yml # Single-service orchestration
120
+ └── .dockerignore
121
+ ```
122
+
123
+ ### Module Responsibilities
124
+
125
+ | Module | Role |
126
+ |---|---|
127
+ | `api_client.py` | `fetch_occurrences()` — queries PBDB with `base_name`, `max_ma`/`min_ma`, and `show=paleoloc,phylo,time,ident`. Returns raw JSON list. `fetch_wikipedia_profile()` — fetches page summary and thumbnail from Wikimedia REST API. Handles timeouts, HTTP errors, and empty responses gracefully. |
128
+ | `data_processor.py` | `clean_occurrence_data()` — 5-step pipeline: temporal filter → impute lone bounds → spatial filter → compute `middle_age` → time-window filter. Returns `(DataFrame, stats_dict)`. `get_dataframe_statistics()` — summary metrics for the UI. Includes a `PBDB_COLUMN_MAP` that normalises abbreviated field names (`eag` → `max_ma`, `tna` → `matched_name`, etc.). |
129
+ | `visualizations.py` | `build_paleo_map()` — Folium map with `MarkerCluster`, `CircleMarker`s, tooltips, and clickable popups. `build_timeline()` — Plotly horizontal range chart with reversed X-axis. |
130
+ | `app.py` | Full Streamlit dashboard: sidebar controls (taxon input, Ma slider, record limit), educational guide expander, summary metrics, pipeline stats, 4-tab layout (Map, Timeline, Taxon Profile, Raw Data & CSV export), persistent tab state, map-click-to-profile integration, and 130+ clickable reference buttons. |
131
+
132
+ ### Data Flow
133
+
134
+ ```
135
+ User Input (sidebar)
136
+
137
+
138
+ api_client.fetch_occurrences() ──► PBDB API
139
+
140
+
141
+ data_processor.records_to_dataframe()
142
+
143
+
144
+ data_processor.clean_occurrence_data() ──► Cleaned DataFrame
145
+
146
+ ├──► visualizations.build_paleo_map() ──► Folium map
147
+ ├──► visualizations.build_timeline() ──► Plotly chart
148
+ ├──► api_client.fetch_wikipedia_profile() ──► Wikipedia
149
+ └──► CSV export
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Usage Guide
155
+
156
+ ### 1. Search for Fossils
157
+
158
+ 1. Open the sidebar (☰)
159
+ 2. Type a taxon name — or click any name in the **Taxon & Clade Reference** at the bottom of the page
160
+ 3. Adjust the **Geological Time Window** slider (default: 65–250 Ma, the Mesozoic)
161
+ 4. Click **🚀 Search PBDB**
162
+
163
+ ### 2. Explore the Results
164
+
165
+ | Tab | What You See |
166
+ |---|---|
167
+ | 🗺️ Paleogeographic Map | Clustered markers on paleocoordinates. Zoom in to see individual fossils. Click a marker to jump to its Taxon Profile. |
168
+ | 📈 Deep-Time Timeline | Horizontal range chart. Older on the left, younger on the right. Hover for details. |
169
+ | 🔍 Taxon Profile | Select any taxon from the dropdown. View Wikipedia summary, image, and link to full article. |
170
+ | 📋 Raw Data & Export | Full cleaned dataset as a sortable table. Click **Download as CSV** to export. |
171
+
172
+ ### 3. Export for Research
173
+
174
+ The CSV export contains all cleaned columns: `matched_name`, `max_ma`, `min_ma`, `middle_age`, `paleolat`, `paleolng`, `early_interval`, `family`, `genus`, `phylum`, `class`, `order`, and more. Ready for:
175
+
176
+ ```r
177
+ # R
178
+ df <- read.csv("paleodata_Ceratopsidae_65_250Ma.csv")
179
+ ```
180
+
181
+ ```python
182
+ # Python
183
+ import pandas as pd
184
+ df = pd.read_csv("paleodata_Ceratopsidae_65_250Ma.csv")
185
+ ```
186
+
187
+ ### Example Queries
188
+
189
+ | Search Term | Expected Records | Notes |
190
+ |---|---|---|
191
+ | `Tyrannosauridae` | ~50–200 | T. rex family and relatives |
192
+ | `Ceratopsidae` | ~100–1000 | Horned dinosaurs |
193
+ | `Ammonoidea` | ~1000+ | Ammonites — huge dataset |
194
+ | `Trilobita` | ~1000+ | Trilobites — wide temporal range |
195
+ | `Homo` | ~100+ | Human lineage |
196
+ | `Mammuthus` | ~50–200 | Mammoths |
197
+ | `Megalodon` | ~10–50 | Giant extinct shark |
198
+
199
+ ---
200
+
201
+ ## API & Domain Notes
202
+
203
+ ### PBDB Field Mapping
204
+
205
+ The PBDB API returns abbreviated keys. PaleoData Explorer normalises them:
206
+
207
+ | PBDB Key | Meaning | Canonical Name |
208
+ |---|---|---|
209
+ | `eag` | Early age (older bound) | `max_ma` |
210
+ | `lag` | Late age (younger bound) | `min_ma` |
211
+ | `tna` | Taxon name | `matched_name` |
212
+ | `oei` | Early interval name | `early_interval` |
213
+ | `pla` | Paleolatitude | `paleolat` |
214
+ | `pln` | Paleolongitude | `paleolng` |
215
+ | `phl` | Phylum | `phylum` |
216
+ | `cll` | Class | `class` |
217
+ | `odl` | Order | `order` |
218
+ | `fml` | Family | `family` |
219
+ | `gnl` | Genus | `genus` |
220
+
221
+ ### Geological Time Convention
222
+
223
+ - **Ma** = Mega-annum (millions of years ago)
224
+ - **Larger numbers** = further back in time
225
+ - All temporal charts display with the **X-axis reversed** (older ← left, younger → right)
226
+
227
+ ### Paleocoordinates vs. Modern Coordinates
228
+
229
+ Modern GPS coordinates tell you where a fossil was *found*. Paleocoordinates tell you where the organism actually *lived*, reconstructed by reversing tectonic plate movements. This app exclusively plots paleocoordinates from the PBDB's GPlates model.
230
+
231
  ---
232
+
233
+ ## Tech Stack
234
+
235
+ | Layer | Technology |
236
+ |---|---|
237
+ | Frontend / UI | [Streamlit](https://streamlit.io) 1.58+ |
238
+ | Data Processing | [Pandas](https://pandas.pydata.org) 3.0+, [NumPy](https://numpy.org) 2.4+ |
239
+ | API Requests | [Requests](https://requests.readthedocs.io) 2.34+ |
240
+ | Map Visualisation | [Folium](https://python-visualization.github.io/folium/) 0.20+ |
241
+ | Charts | [Plotly](https://plotly.com/python/) 6.8+ |
242
+ | Containerisation | [Docker](https://docker.com), Python 3.11-slim |
243
+
244
  ---
245
 
246
+ ## Contributing
247
+
248
+ Contributions are welcome. Areas of interest:
249
+
250
+ - Adding Macrostrat geological period overlays
251
+ - Supporting additional PBDB output formats
252
+ - Adding stratigraphic column visualisations
253
+ - Improving test coverage
254
+ - i18n / translations
255
+
256
+ Please open an issue or pull request on GitHub.
257
+
258
+ ---
259
+
260
+ ## License
261
+
262
+ This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
263
+
264
+ ---
265
+
266
+ ## Acknowledgements
267
+
268
+ - Fossil occurrence data via the [Paleobiology Database](https://paleobiodb.org) (CC BY 4.0)
269
+ - Taxon summaries and images via the [Wikimedia REST API](https://www.mediawiki.org/wiki/API:REST_API) (CC BY-SA 3.0 / various)
270
+ - Paleocoordinates reconstructed using the [GPlates](https://www.gplates.org) model
271
+ - Built with [Streamlit](https://streamlit.io), [Folium](https://python-visualization.github.io/folium/), and [Plotly](https://plotly.com)
api_client.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PaleoData Explorer — PBDB & Macrostrat API Client
3
+ ==================================================
4
+ Provides robust, cached-friendly wrappers around the Paleobiology Database
5
+ (PBDB) occurrence endpoint and (optionally) the Macrostrat interval
6
+ endpoint. Every function handles timeouts, HTTP errors, and empty
7
+ responses gracefully.
8
+
9
+ Domain notes
10
+ ------------
11
+ * Geological time is in "Ma" (Mega-annum, millions of years ago).
12
+ * The "show" parameter must include `paleoloc` (paleocoordinates),
13
+ `phylo` (phylogeny / taxonomy) and `time,ident` so the returned JSON
14
+ carries `paleolat`, `paleolng`, taxonomic hierarchies and temporal
15
+ bounds (`max_ma`, `min_ma`).
16
+ * The PBDB API returns `records` inside a top-level key; we safely
17
+ unwrap that in `fetch_occurrences`.
18
+ """
19
+
20
+ import logging
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ import requests
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Constants
29
+ # ---------------------------------------------------------------------------
30
+
31
+ PBDB_OCCURRENCE_URL: str = "https://paleobiodb.org/data1.2/occs/list.json"
32
+ MACROSTRAT_INTERVALS_URL: str = "https://macrostrat.org/api/v2/defs/intervals"
33
+
34
+ DEFAULT_LIMIT: int = 1000
35
+ DEFAULT_SHOW: str = "paleoloc,phylo,time,ident"
36
+ REQUEST_TIMEOUT: int = 30 # seconds
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Public helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _safe_get(url: str, params: Dict[str, Any]) -> requests.Response:
44
+ """Perform a GET request with standardised error handling.
45
+
46
+ Raises
47
+ ------
48
+ requests.exceptions.Timeout
49
+ When the request hangs past *REQUEST_TIMEOUT*.
50
+ requests.exceptions.HTTPError
51
+ On 4xx / 5xx responses.
52
+ ValueError
53
+ When the response body is not valid JSON.
54
+ """
55
+ logger.debug("GET %s | params=%s", url, params)
56
+ resp = requests.get(url, params=params, timeout=REQUEST_TIMEOUT)
57
+ resp.raise_for_status()
58
+ return resp
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # PBDB Occurrence fetch
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def fetch_occurrences(
66
+ base_name: str,
67
+ *,
68
+ max_ma: Optional[float] = None,
69
+ min_ma: Optional[float] = None,
70
+ limit: int = DEFAULT_LIMIT,
71
+ show: str = DEFAULT_SHOW,
72
+ ) -> List[Dict[str, Any]]:
73
+ """Fetch fossil occurrence records from the PBDB API.
74
+
75
+ Parameters
76
+ ----------
77
+ base_name : str
78
+ Taxonomic clade or genus name, e.g. ``"Ceratopsidae"`` or
79
+ ``"Tyrannosaurus"``. PBDB resolves this hierarchically so all
80
+ subordinate taxa are included automatically.
81
+ max_ma, min_ma : float or None
82
+ Optional temporal window in Ma. When supplied the API filters
83
+ occurrences to those whose age range overlaps this window.
84
+ limit : int
85
+ Maximum number of records to return (default 1 000).
86
+ show : str
87
+ Comma-separated list of PBDB "show" fields. Must include at
88
+ least ``paleoloc,phylo,time,ident`` for the downstream pipeline.
89
+
90
+ Returns
91
+ -------
92
+ list[dict]
93
+ List of raw occurrence records. Returns an empty list when the
94
+ API returns no records or the response is malformed.
95
+
96
+ Raises
97
+ ------
98
+ requests.exceptions.Timeout
99
+ If the PBDB API does not respond within *REQUEST_TIMEOUT*.
100
+ requests.exceptions.HTTPError
101
+ If the API returns a non-200 status.
102
+ ValueError
103
+ If the response body cannot be parsed as JSON.
104
+ """
105
+ params: Dict[str, Any] = {
106
+ "base_name": base_name,
107
+ "show": show,
108
+ "limit": limit,
109
+ }
110
+ if max_ma is not None:
111
+ params["max_ma"] = max_ma
112
+ if min_ma is not None:
113
+ params["min_ma"] = min_ma
114
+
115
+ logger.info("Querying PBDB with base_name=%r", base_name)
116
+
117
+ try:
118
+ resp = _safe_get(PBDB_OCCURRENCE_URL, params)
119
+ except requests.exceptions.Timeout:
120
+ logger.error("PBDB request timed out after %d s", REQUEST_TIMEOUT)
121
+ raise
122
+ except requests.exceptions.HTTPError as exc:
123
+ logger.error("PBDB request failed (HTTP %s)", exc.response.status_code if exc.response is not None else "unknown")
124
+ raise
125
+ except requests.exceptions.RequestException as exc:
126
+ logger.error("PBDB request failed: %s", exc)
127
+ raise
128
+
129
+ try:
130
+ data = resp.json()
131
+ except ValueError:
132
+ logger.error("PBDB response body is not valid JSON")
133
+ raise
134
+
135
+ records: List[Dict[str, Any]] = data.get("records", [])
136
+ if not records:
137
+ logger.warning("PBDB returned zero records for base_name=%r", base_name)
138
+
139
+ logger.info("PBDB returned %d records", len(records))
140
+ return records
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Wikipedia profile fetch (optional helper)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ WIKIPEDIA_SUMMARY_URL: str = "https://en.wikipedia.org/api/rest_v1/page/summary"
148
+
149
+
150
+ def fetch_wikipedia_profile(taxon_name: str) -> Dict[str, Any]:
151
+ """Fetch a short summary and thumbnail for a taxon from Wikipedia.
152
+
153
+ Uses the Wikimedia REST API ``/page/summary/{title}`` endpoint.
154
+ Failures are caught silently and an empty dict (or dict with an
155
+ ``"error"`` key) is returned so callers never crash.
156
+
157
+ Parameters
158
+ ----------
159
+ taxon_name : str
160
+ The Wikipedia article title, e.g. ``"Triceratops"`` or
161
+ ``"Tyrannosaurus"``.
162
+
163
+ Returns
164
+ -------
165
+ dict
166
+ On success: ``{"extract": str, "image_url": str|None, "page_url": str}``.
167
+ On failure: ``{"error": str}`` or ``{}``.
168
+ """
169
+ import urllib.parse
170
+
171
+ safe_title = urllib.parse.quote(taxon_name.strip(), safe="")
172
+ url = f"{WIKIPEDIA_SUMMARY_URL}/{safe_title}"
173
+
174
+ logger.info("Fetching Wikipedia summary for %r", taxon_name)
175
+
176
+ try:
177
+ resp = requests.get(
178
+ url,
179
+ timeout=REQUEST_TIMEOUT,
180
+ headers={"User-Agent": "PaleoDataExplorer/1.0 (educational tool; https://github.com/anomalyco/opencode)"},
181
+ )
182
+ if resp.status_code == 404:
183
+ logger.warning("Wikipedia page not found for %r", taxon_name)
184
+ return {"error": f"No Wikipedia article found for '{taxon_name}'."}
185
+ resp.raise_for_status()
186
+ except requests.exceptions.Timeout:
187
+ logger.error("Wikipedia request timed out for %r", taxon_name)
188
+ return {"error": "Wikipedia request timed out."}
189
+ except requests.exceptions.RequestException as exc:
190
+ logger.error("Wikipedia request failed for %r: %s", taxon_name, exc)
191
+ return {"error": f"Wikipedia request failed: {exc}"}
192
+
193
+ try:
194
+ data = resp.json()
195
+ except ValueError:
196
+ logger.error("Wikipedia response is not valid JSON for %r", taxon_name)
197
+ return {"error": "Invalid response from Wikipedia."}
198
+
199
+ extract = data.get("extract", "")
200
+ thumbnail = data.get("thumbnail", {})
201
+ image_url = thumbnail.get("source") if isinstance(thumbnail, dict) else None
202
+ page_url = data.get("content_urls", {}).get("desktop", {}).get("page", "")
203
+
204
+ return {
205
+ "extract": extract,
206
+ "image_url": image_url,
207
+ "page_url": page_url,
208
+ }
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Macrostrat interval fetch (optional helper)
213
+ # ---------------------------------------------------------------------------
214
+
215
+ def fetch_macrostrat_intervals() -> List[Dict[str, Any]]:
216
+ """Fetch the Macrostrat interval definitions (geological periods).
217
+
218
+ Useful for mapping absolute Ma values to named periods. Returns an
219
+ empty list on failure so callers can fall back gracefully.
220
+
221
+ Returns
222
+ -------
223
+ list[dict]
224
+ Each dict contains keys such as ``name``, ``t_age``, ``b_age``,
225
+ ``color``, etc.
226
+ """
227
+ logger.info("Querying Macrostrat interval definitions")
228
+ try:
229
+ resp = _safe_get(MACROSTRAT_INTERVALS_URL, {"all": True, "format": "json"})
230
+ return resp.json() # type: ignore[no-any-return]
231
+ except Exception:
232
+ logger.exception("Failed to fetch Macrostrat intervals")
233
+ return []
app.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PaleoData Explorer — Streamlit Application
3
+ ===========================================
4
+ Professional-grade dashboard for querying, cleaning, visualising, and
5
+ exporting fossil occurrence data from the Paleobiology Database (PBDB).
6
+
7
+ Run with::
8
+
9
+ streamlit run app.py
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import logging
16
+
17
+ import pandas as pd
18
+ import streamlit as st
19
+ from streamlit_folium import st_folium
20
+
21
+ from api_client import fetch_macrostrat_intervals, fetch_occurrences, fetch_wikipedia_profile
22
+ from data_processor import (
23
+ clean_occurrence_data,
24
+ get_dataframe_statistics,
25
+ records_to_dataframe,
26
+ )
27
+ from visualizations import build_paleo_map, build_timeline
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Logging
31
+ # ---------------------------------------------------------------------------
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
35
+ )
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Cached helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ @st.cache_data(ttl=3600, show_spinner=False)
44
+ def cached_fetch_wikipedia_profile(taxon_name: str) -> dict:
45
+ """Streamlit-cached wrapper around :func:`fetch_wikipedia_profile`.
46
+
47
+ Results are cached for 1 hour so repeated selections of the same
48
+ taxon do not re-hit the Wikipedia API.
49
+ """
50
+ return fetch_wikipedia_profile(taxon_name)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Reference-list helper: render clickable taxon buttons
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def _clickable_taxon(taxon: str, desc: str = "") -> None:
58
+ """Render a small clickable button that auto-fills the search bar.
59
+
60
+ Uses an intermediary ``_pending_taxon`` session-state key to avoid
61
+ Streamlit's restriction on modifying a widget's key after the widget
62
+ has already been instantiated during the same render pass.
63
+ """
64
+ label = f"{taxon}"
65
+ if desc:
66
+ label += f" — {desc}"
67
+ if st.button(label, key=f"refbtn_{taxon}", help=f'Search PBDB for "{taxon}"', use_container_width=True):
68
+ st.session_state["_pending_taxon"] = taxon
69
+ st.rerun()
70
+
71
+
72
+ def _clickable_taxa_section(header: str, taxa: list[tuple[str, str]], num_cols: int = 3) -> None:
73
+ """Render a labelled group of :func:`_clickable_taxon` buttons in columns.
74
+
75
+ Parameters
76
+ ----------
77
+ header : str
78
+ Bold markdown heading for the group.
79
+ taxa : list[tuple[str, str]]
80
+ Each tuple is ``(taxon_name, short_description)``.
81
+ num_cols : int
82
+ Number of button columns.
83
+ """
84
+ st.markdown(f"**{header}**")
85
+ cols = st.columns(num_cols)
86
+ for i, (taxon, desc) in enumerate(taxa):
87
+ with cols[i % num_cols]:
88
+ _clickable_taxon(taxon, desc)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Page config
93
+ # ---------------------------------------------------------------------------
94
+ st.set_page_config(
95
+ page_title="PaleoData Explorer",
96
+ page_icon="🦴",
97
+ layout="wide",
98
+ initial_sidebar_state="expanded",
99
+ )
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Intermediary state: allow downstream buttons to request a taxon change
103
+ # or active-tab switch without violating Streamlit's widget-key immutability rule.
104
+ # ---------------------------------------------------------------------------
105
+ if st.session_state.get("_pending_taxon"):
106
+ st.session_state["taxon_input"] = st.session_state.pop("_pending_taxon")
107
+ if st.session_state.get("_pending_active_tab"):
108
+ st.session_state["active_tab"] = st.session_state.pop("_pending_active_tab")
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Sidebar — Query controls
112
+ # ---------------------------------------------------------------------------
113
+ st.sidebar.title("🔍 Query Controls")
114
+
115
+ # Initialise session state for the taxon input if not already set
116
+ if "taxon_input" not in st.session_state:
117
+ st.session_state["taxon_input"] = "Ceratopsidae"
118
+
119
+ taxon_input = st.sidebar.text_input(
120
+ "Taxon / Clade",
121
+ key="taxon_input",
122
+ help="Enter a taxonomic name, e.g. 'Tyrannosauridae', 'Mammalia', or 'Triceratops'.",
123
+ )
124
+
125
+ st.sidebar.markdown("---")
126
+ st.sidebar.subheader("Geological Time Window (Ma)")
127
+
128
+ ma_min, ma_max = st.sidebar.slider(
129
+ "Select age range (millions of years ago)",
130
+ min_value=0.0,
131
+ max_value=500.0,
132
+ value=(65.0, 250.0),
133
+ step=5.0,
134
+ help="Older = larger number. The PBDB will return fossils whose age ranges overlap this window.",
135
+ )
136
+
137
+ max_records = st.sidebar.number_input(
138
+ "Max records",
139
+ min_value=100,
140
+ max_value=5000,
141
+ value=1000,
142
+ step=100,
143
+ help="Maximum number of occurrence records to fetch from the PBDB API.",
144
+ )
145
+
146
+ st.sidebar.markdown("---")
147
+
148
+ search_clicked = st.sidebar.button(
149
+ "🚀 Search PBDB",
150
+ type="primary",
151
+ use_container_width=True,
152
+ )
153
+
154
+ st.sidebar.markdown("---")
155
+ st.sidebar.caption(
156
+ "Data sourced from the [Paleobiology Database](https://paleobiodb.org) "
157
+ "and [Macrostrat](https://macrostrat.org)."
158
+ )
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # App title
162
+ # ---------------------------------------------------------------------------
163
+ st.title("🦴 PaleoData Explorer")
164
+ st.markdown(
165
+ "Query, clean, visualise, and export fossil occurrence records from "
166
+ "the **Paleobiology Database** (PBDB). Paleocoordinates are mapped "
167
+ "on the paleogeographic globe; timelines follow geological convention "
168
+ "(older → younger = left → right)."
169
+ )
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Educational Guide (collapsed by default)
173
+ # ---------------------------------------------------------------------------
174
+ with st.expander("📖 Welcome to PaleoData Explorer: Guide & Glossary", expanded=False):
175
+ st.markdown(
176
+ """
177
+ ### What is PaleoData Explorer?
178
+
179
+ This is a professional data-wrangling dashboard that connects to the real
180
+ **[Paleobiology Database (PBDB)](https://paleobiodb.org)** — the same
181
+ database used by working paleontologists worldwide. Instead of browsing
182
+ static tables, you can search, visualise, and download fossil occurrence
183
+ records in seconds.
184
+
185
+ ---
186
+
187
+ ### Key Concepts & Glossary
188
+
189
+ | Term | Meaning |
190
+ |---|---|
191
+ | **Ma (Mega-annum)** | Millions of years ago. *Larger numbers = further back in time.* All timelines in this app flow **backwards** (older on the left, younger on the right), following geological convention. |
192
+ | **Paleocoordinates** | The reconstructed latitude/longitude of a fossil **at the time the organism lived**, accounting for millions of years of tectonic plate movement. This map shows where the animal actually lived — *not* where its bones happen to sit today. |
193
+ | **Clade / Taxon** | A group of organisms sharing a common ancestor. Searching for a family name (e.g. "Tyrannosauridae") automatically includes all subordinate genera and species. |
194
+ | **Stratigraphic Range** | The span of geological time during which a taxon is known to have existed, bounded by its oldest (*max_ma*) and youngest (*min_ma*) fossil occurrences. |
195
+ | **Middle Age** | The midpoint of a fossil's stratigraphic range: `(max_ma + min_ma) / 2`. Used as a single-point estimate for temporal plotting. |
196
+ | **PBDB** | The Paleobiology Database — a public, community-driven repository of fossil occurrence data curated by hundreds of research scientists. |
197
+
198
+ ---
199
+
200
+ ### How to Use This App
201
+
202
+ 1. **Enter a taxon name** in the sidebar (e.g. *Ceratopsidae*, *Tyrannosaurus*, *Mammalia*).
203
+ 2. **Adjust the time slider** to narrow the geological window (default: 65–250 Ma, the Mesozoic).
204
+ 3. Click **Search PBDB** to fetch records.
205
+ 4. Explore the results across three tabs:
206
+ - 🗺️ **Paleogeographic Map** — clustered markers on paleocoordinates.
207
+ - 📈 **Deep-Time Timeline** — stratigraphic range chart.
208
+ - 🔍 **Taxon Profile** — Wikipedia summary and image for any taxon in the results.
209
+ 5. 📥 **Download** the cleaned dataset as a CSV for your own analysis.
210
+ """
211
+ )
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Session state initialisation
215
+ # ---------------------------------------------------------------------------
216
+ DEFAULT_STATE = {
217
+ "df_clean": pd.DataFrame(),
218
+ "stats_raw": {},
219
+ "query_taxon": "",
220
+ "query_ma_min": None,
221
+ "query_ma_max": None,
222
+ "last_error": "",
223
+ "has_searched": False,
224
+ }
225
+ for key, default in DEFAULT_STATE.items():
226
+ if key not in st.session_state:
227
+ st.session_state[key] = default
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Fetch & process logic
231
+ # ---------------------------------------------------------------------------
232
+ def run_query() -> None:
233
+ """Execute the full fetch → clean pipeline and store results in session."""
234
+ st.session_state["last_error"] = ""
235
+ st.session_state["has_searched"] = True
236
+ st.session_state["query_taxon"] = taxon_input.strip()
237
+ st.session_state["query_ma_min"] = ma_min
238
+ st.session_state["query_ma_max"] = ma_max
239
+
240
+ with st.spinner(f"Querying PBDB for '{st.session_state['query_taxon']}' …"):
241
+ try:
242
+ raw_records = fetch_occurrences(
243
+ base_name=st.session_state["query_taxon"],
244
+ max_ma=ma_max,
245
+ min_ma=ma_min,
246
+ limit=int(max_records),
247
+ )
248
+ except Exception as exc:
249
+ logger.exception("PBDB query failed")
250
+ st.session_state["last_error"] = f"API error: {exc}"
251
+ st.session_state["df_clean"] = pd.DataFrame()
252
+ st.session_state["stats_raw"] = {}
253
+ return
254
+
255
+ if not raw_records:
256
+ st.session_state["last_error"] = (
257
+ f"No records found for '{st.session_state['query_taxon']}' "
258
+ f"within {ma_min:.0f}–{ma_max:.0f} Ma."
259
+ )
260
+ st.session_state["df_clean"] = pd.DataFrame()
261
+ st.session_state["stats_raw"] = {}
262
+ return
263
+
264
+ df_raw = records_to_dataframe(raw_records)
265
+ df_clean, pipeline_stats = clean_occurrence_data(df_raw, max_ma=ma_max, min_ma=ma_min)
266
+
267
+ st.session_state["df_clean"] = df_clean
268
+ st.session_state["stats_raw"] = pipeline_stats
269
+
270
+
271
+ if search_clicked:
272
+ run_query()
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Results area
276
+ # ---------------------------------------------------------------------------
277
+ if st.session_state.get("last_error"):
278
+ st.error(st.session_state["last_error"])
279
+
280
+ if st.session_state.get("has_searched") and not st.session_state.get("last_error"):
281
+ st.success(
282
+ f"Query: **{st.session_state['query_taxon']}** | "
283
+ f"Time window: {st.session_state['query_ma_min']:.0f}–{st.session_state['query_ma_max']:.0f} Ma"
284
+ )
285
+
286
+ df = st.session_state["df_clean"]
287
+
288
+ if df.empty:
289
+ st.warning("No records survived the cleaning pipeline. Try broadening your search.")
290
+ st.stop()
291
+
292
+ # ---- Summary stats --------------------------------------------------
293
+ summary = get_dataframe_statistics(df)
294
+ cols = st.columns(5)
295
+ cols[0].metric("Records", summary.get("record_count", 0))
296
+ cols[1].metric("Unique Taxa", summary.get("unique_taxa", 0))
297
+ cols[2].metric("Oldest (Ma)", f"{summary.get('oldest_ma', 0):.1f}")
298
+ cols[3].metric("Youngest (Ma)", f"{summary.get('youngest_ma', 0):.1f}")
299
+ cols[4].metric("Time Span (Ma)", f"{summary.get('time_span_ma', 0):.1f}")
300
+
301
+ # ---- Pipeline stats -------------------------------------------------
302
+ with st.expander("📊 Data Pipeline Details", expanded=False):
303
+ stats = st.session_state.get("stats_raw", {})
304
+ c1, c2, c3, c4 = st.columns(4)
305
+ c1.metric("Fetched", stats.get("raw", 0))
306
+ c2.metric("Has temporal", stats.get("has_temporal", 0))
307
+ c3.metric("Has spatial", stats.get("has_spatial", 0))
308
+ c4.metric("In time window", stats.get("in_window", 0))
309
+
310
+ # ---- Tab selector (radio, so it persists across reruns) -------------
311
+ TAB_OPTIONS = [
312
+ "🗺️ Paleogeographic Map",
313
+ "📈 Deep-Time Timeline",
314
+ "🔍 Taxon Profile",
315
+ "📋 Raw Data & Export",
316
+ ]
317
+ if "active_tab" not in st.session_state:
318
+ st.session_state["active_tab"] = TAB_OPTIONS[0]
319
+
320
+ active_tab = st.radio(
321
+ "",
322
+ TAB_OPTIONS,
323
+ horizontal=True,
324
+ key="active_tab",
325
+ label_visibility="collapsed",
326
+ )
327
+
328
+ # ------------------------------------------------------------------
329
+ # Tab 1: Paleogeographic Map
330
+ # ------------------------------------------------------------------
331
+ if active_tab == TAB_OPTIONS[0]:
332
+ st.subheader("Fossil Occurrences — Paleocoordinates")
333
+ st.caption(
334
+ "Points are plotted at their **paleocoordinates** — where the "
335
+ "organism lived millions of years ago, accounting for continental drift. "
336
+ "🖱️ **Click a marker** to jump to its Taxon Profile."
337
+ )
338
+ paleo_map = build_paleo_map(df, height=600)
339
+ map_data = st_folium(paleo_map, height=600, width=700)
340
+
341
+ # Detect map-marker click → switch to Taxon Profile tab
342
+ if map_data and map_data.get("last_object_clicked_popup"):
343
+ clicked_taxon = str(map_data["last_object_clicked_popup"]).strip()
344
+ if clicked_taxon:
345
+ st.session_state["profile_taxon"] = clicked_taxon
346
+ st.session_state["_pending_active_tab"] = TAB_OPTIONS[2]
347
+ st.rerun()
348
+
349
+ # ------------------------------------------------------------------
350
+ # Tab 2: Deep-Time Timeline
351
+ # ------------------------------------------------------------------
352
+ elif active_tab == TAB_OPTIONS[1]:
353
+ st.subheader("Stratigraphic Range Chart")
354
+ st.caption("X‑axis is **reversed** (older on left, younger on right) — geological convention.")
355
+ fig_timeline = build_timeline(df, max_taxa=50)
356
+ st.plotly_chart(fig_timeline, use_container_width=True)
357
+
358
+ # ------------------------------------------------------------------
359
+ # Tab 3: Taxon Profile
360
+ # ------------------------------------------------------------------
361
+ elif active_tab == TAB_OPTIONS[2]:
362
+ st.subheader("Taxon Profile Viewer")
363
+ st.caption(
364
+ "Select a taxon from the dropdown to view a summary and image "
365
+ "sourced from Wikipedia."
366
+ )
367
+
368
+ unique_taxa = sorted(df["matched_name"].dropna().unique())
369
+ if len(unique_taxa) == 0:
370
+ st.info("No named taxa available in the current results.")
371
+ else:
372
+ # Honour a map-click pre-selection
373
+ if "profile_taxon" not in st.session_state:
374
+ st.session_state["profile_taxon"] = unique_taxa[0]
375
+ default_index = 0
376
+ if st.session_state["profile_taxon"] in unique_taxa:
377
+ default_index = unique_taxa.index(st.session_state["profile_taxon"])
378
+
379
+ selected_taxon = st.selectbox(
380
+ "Choose a taxon",
381
+ options=unique_taxa,
382
+ index=default_index,
383
+ key="profile_selectbox",
384
+ help="Taxa are drawn from the matched_name field in the cleaned dataset.",
385
+ )
386
+ # Sync the pick back to session state
387
+ st.session_state["profile_taxon"] = selected_taxon
388
+
389
+ if selected_taxon:
390
+ genus_candidate = df.loc[
391
+ df["matched_name"] == selected_taxon, "genus"
392
+ ]
393
+ lookup_name = selected_taxon
394
+ if not genus_candidate.empty and genus_candidate.notna().any():
395
+ genus_val = str(genus_candidate.iloc[0])
396
+ if genus_val and genus_val != "nan":
397
+ lookup_name = genus_val
398
+
399
+ with st.spinner(f"Fetching Wikipedia summary for '{lookup_name}' …"):
400
+ profile = cached_fetch_wikipedia_profile(lookup_name)
401
+
402
+ if profile.get("error"):
403
+ if lookup_name != selected_taxon:
404
+ with st.spinner(f"Trying '{selected_taxon}' …"):
405
+ profile = cached_fetch_wikipedia_profile(selected_taxon)
406
+
407
+ if profile.get("error"):
408
+ st.warning(profile["error"])
409
+ else:
410
+ col_img, col_text = st.columns([1, 2])
411
+ with col_img:
412
+ image_url = profile.get("image_url")
413
+ if image_url:
414
+ st.image(image_url, caption=lookup_name, use_container_width=True)
415
+ else:
416
+ st.info("No image available.")
417
+ with col_text:
418
+ extract = profile.get("extract", "")
419
+ if extract:
420
+ st.markdown(extract)
421
+ else:
422
+ st.info("No summary text available.")
423
+ page_url = profile.get("page_url")
424
+ if page_url:
425
+ st.caption(f"[Read more on Wikipedia]({page_url})")
426
+
427
+ # ------------------------------------------------------------------
428
+ # Tab 4: Raw Data & Export
429
+ # ------------------------------------------------------------------
430
+ elif active_tab == TAB_OPTIONS[3]:
431
+ st.subheader("Cleaned Occurrence Data")
432
+ st.dataframe(
433
+ df,
434
+ use_container_width=True,
435
+ column_config={
436
+ "paleolat": st.column_config.NumberColumn("Paleolat (°)", format="%.4f"),
437
+ "paleolng": st.column_config.NumberColumn("Paleolng (°)", format="%.4f"),
438
+ "max_ma": st.column_config.NumberColumn("Max Age (Ma)", format="%.2f"),
439
+ "min_ma": st.column_config.NumberColumn("Min Age (Ma)", format="%.2f"),
440
+ "middle_age": st.column_config.NumberColumn("Middle Age (Ma)", format="%.2f"),
441
+ },
442
+ hide_index=True,
443
+ )
444
+
445
+ # -- CSV export ---------------------------------------------------
446
+ csv_buffer = io.StringIO()
447
+ df.to_csv(csv_buffer, index=False)
448
+ st.download_button(
449
+ label="📥 Download as CSV",
450
+ data=csv_buffer.getvalue(),
451
+ file_name=f"paleodata_{st.session_state['query_taxon']}_{ma_min:.0f}_{ma_max:.0f}Ma.csv",
452
+ mime="text/csv",
453
+ type="primary",
454
+ )
455
+
456
+ else:
457
+ # Landing state — show instructions
458
+ st.info(
459
+ "Enter a taxon name in the sidebar (e.g. **Ceratopsidae**, "
460
+ "**Tyrannosauridae**, **Mammalia**) and click **Search PBDB** "
461
+ "to begin exploring the fossil record."
462
+ )
463
+ st.markdown(
464
+ """
465
+ ### What this app does
466
+ 1. **Queries** the Paleobiology Database for fossil occurrences.
467
+ 2. **Cleans** the raw data (drops records missing age or coordinates).
468
+ 3. **Visualises** results on a paleogeographic map and a deep-time timeline.
469
+ 4. **Exports** the cleaned dataset as a CSV for your own analysis.
470
+ """
471
+ )
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # 📚 Taxon & Clade Reference (always visible)
475
+ # ---------------------------------------------------------------------------
476
+ st.markdown("---")
477
+ st.subheader("📚 Taxon & Clade Reference")
478
+ st.caption(
479
+ "Click any name below to auto-fill the sidebar search box. "
480
+ "Larger clades (families, orders) return more records; individual genera return more focused results."
481
+ )
482
+
483
+ with st.expander("🦖 Dinosauria — Dinosaurs", expanded=False):
484
+ col_a, col_b, col_c = st.columns(3)
485
+
486
+ with col_a:
487
+ _clickable_taxa_section("Theropoda (meat-eaters)", [
488
+ ("Tyrannosauridae", "T. rex family"),
489
+ ("Tyrannosaurus", ""),
490
+ ("Spinosauridae", ""),
491
+ ("Spinosaurus", ""),
492
+ ("Allosauridae", ""),
493
+ ("Allosaurus", ""),
494
+ ("Dromaeosauridae", "raptors"),
495
+ ("Velociraptor", ""),
496
+ ("Troodontidae", ""),
497
+ ("Coelophysoidea", ""),
498
+ ("Abelisauridae", ""),
499
+ ("Carcharodontosauridae", ""),
500
+ ("Giganotosaurus", ""),
501
+ ("Compsognathidae", ""),
502
+ ("Ornithomimidae", ""),
503
+ ("Oviraptoridae", ""),
504
+ ("Therizinosauridae", ""),
505
+ ], num_cols=1)
506
+
507
+ with col_b:
508
+ _clickable_taxa_section("Sauropodomorpha (long-necks)", [
509
+ ("Sauropoda", ""),
510
+ ("Titanosauria", ""),
511
+ ("Brachiosauridae", ""),
512
+ ("Brachiosaurus", ""),
513
+ ("Diplodocidae", ""),
514
+ ("Diplodocus", ""),
515
+ ("Apatosaurus", ""),
516
+ ("Camarasauridae", ""),
517
+ ("Dicraeosauridae", ""),
518
+ ], num_cols=1)
519
+
520
+ _clickable_taxa_section("Ornithischia (bird-hipped)", [
521
+ ("Stegosauridae", ""),
522
+ ("Stegosaurus", ""),
523
+ ("Ankylosauridae", ""),
524
+ ("Ankylosaurus", ""),
525
+ ("Nodosauridae", ""),
526
+ ], num_cols=1)
527
+
528
+ with col_c:
529
+ _clickable_taxa_section("Marginocephalia", [
530
+ ("Ceratopsidae", "horned dinos"),
531
+ ("Triceratops", ""),
532
+ ("Centrosaurus", ""),
533
+ ("Styracosaurus", ""),
534
+ ("Pachycephalosauridae", ""),
535
+ ("Pachycephalosaurus", ""),
536
+ ], num_cols=1)
537
+
538
+ _clickable_taxa_section("Ornithopoda", [
539
+ ("Hadrosauridae", "duck-bills"),
540
+ ("Edmontosaurus", ""),
541
+ ("Parasaurolophus", ""),
542
+ ("Iguanodontidae", ""),
543
+ ("Iguanodon", ""),
544
+ ("Hypsilophodontidae", ""),
545
+ ], num_cols=1)
546
+
547
+ with st.expander("🦕 Other Mesozoic Reptiles", expanded=False):
548
+ col_a, col_b, col_c = st.columns(3)
549
+
550
+ with col_a:
551
+ _clickable_taxa_section("Pterosauria (flying reptiles)", [
552
+ ("Pterosauria", ""),
553
+ ("Pterodactylidae", ""),
554
+ ("Pteranodon", ""),
555
+ ("Azhdarchidae", ""),
556
+ ("Quetzalcoatlus", ""),
557
+ ("Rhamphorhynchidae", ""),
558
+ ], num_cols=1)
559
+
560
+ with col_b:
561
+ _clickable_taxa_section("Marine Reptiles", [
562
+ ("Ichthyosauria", ""),
563
+ ("Ichthyosaurus", ""),
564
+ ("Plesiosauria", ""),
565
+ ("Plesiosaurus", ""),
566
+ ("Elasmosauridae", ""),
567
+ ("Pliosauridae", ""),
568
+ ("Mosasauridae", ""),
569
+ ("Mosasaurus", ""),
570
+ ], num_cols=1)
571
+
572
+ _clickable_taxa_section("Other Diapsids", [
573
+ ("Crocodylomorpha", ""),
574
+ ("Choristodera", ""),
575
+ ], num_cols=1)
576
+
577
+ with col_c:
578
+ _clickable_taxa_section("Synapsids (mammal ancestors)", [
579
+ ("Therapsida", ""),
580
+ ("Dicynodontia", ""),
581
+ ("Cynodontia", ""),
582
+ ("Dimetrodon", ""),
583
+ ("Lystrosaurus", ""),
584
+ ], num_cols=1)
585
+
586
+ with st.expander("🐘 Mammalia — Mammals", expanded=False):
587
+ col_a, col_b, col_c = st.columns(3)
588
+
589
+ with col_a:
590
+ _clickable_taxa_section("Primates & Relatives", [
591
+ ("Primates", ""),
592
+ ("Hominidae", ""),
593
+ ("Homo", ""),
594
+ ("Australopithecus", ""),
595
+ ("Plesiadapiformes", ""),
596
+ ], num_cols=1)
597
+
598
+ with col_b:
599
+ _clickable_taxa_section("Ungulates & Large Herbivores", [
600
+ ("Proboscidea", "elephants"),
601
+ ("Mammuthus", "mammoths"),
602
+ ("Mammut", "mastodons"),
603
+ ("Perissodactyla", "horses, rhinos"),
604
+ ("Equidae", "horses"),
605
+ ("Equus", ""),
606
+ ("Rhinocerotidae", ""),
607
+ ("Brontotheriidae", ""),
608
+ ("Artiodactyla", "even-toed"),
609
+ ("Camelidae", ""),
610
+ ("Bovidae", ""),
611
+ ("Cervidae", "deer"),
612
+ ], num_cols=1)
613
+
614
+ with col_c:
615
+ _clickable_taxa_section("Carnivora & Others", [
616
+ ("Carnivora", "meat-eaters"),
617
+ ("Felidae", "cats"),
618
+ ("Smilodon", "sabre-tooth"),
619
+ ("Canidae", "dogs"),
620
+ ("Ursidae", "bears"),
621
+ ("Cetacea", "whales"),
622
+ ("Basilosauridae", ""),
623
+ ("Chiroptera", "bats"),
624
+ ("Rodentia", "rodents"),
625
+ ("Xenarthra", "sloths, armadillos"),
626
+ ("Megatherium", "giant sloth"),
627
+ ("Marsupialia", ""),
628
+ ], num_cols=1)
629
+
630
+ with st.expander("🦈 Marine Life & Invertebrates", expanded=False):
631
+ col_a, col_b, col_c = st.columns(3)
632
+
633
+ with col_a:
634
+ _clickable_taxa_section("Fish", [
635
+ ("Chondrichthyes", "sharks & rays"),
636
+ ("Carcharodon", "great white"),
637
+ ("Megalodon", ""),
638
+ ("Osteichthyes", "bony fish"),
639
+ ("Actinopterygii", ""),
640
+ ("Sarcopterygii", "lobe-finned"),
641
+ ("Coelacanthiformes", ""),
642
+ ("Placodermi", ""),
643
+ ], num_cols=1)
644
+
645
+ with col_b:
646
+ _clickable_taxa_section("Molluscs", [
647
+ ("Ammonoidea", "ammonites"),
648
+ ("Nautiloidea", ""),
649
+ ("Bivalvia", "clams, oysters"),
650
+ ("Gastropoda", "snails"),
651
+ ("Belemnitida", ""),
652
+ ("Coleoidea", "squid, octopus"),
653
+ ], num_cols=1)
654
+
655
+ _clickable_taxa_section("Other Invertebrates", [
656
+ ("Trilobita", "trilobites"),
657
+ ("Eurypterida", "sea scorpions"),
658
+ ], num_cols=1)
659
+
660
+ with col_c:
661
+ _clickable_taxa_section("Corals, Sponges & More", [
662
+ ("Rugosa", "horn corals"),
663
+ ("Tabulata", ""),
664
+ ("Scleractinia", "stony corals"),
665
+ ("Porifera", "sponges"),
666
+ ("Stromatoporoidea", ""),
667
+ ("Brachiopoda", ""),
668
+ ("Bryozoa", ""),
669
+ ("Echinodermata", ""),
670
+ ("Crinoidea", "sea lilies"),
671
+ ("Echinoidea", "sea urchins"),
672
+ ("Graptolithina", ""),
673
+ ("Foraminifera", ""),
674
+ ], num_cols=1)
675
+
676
+ with st.expander("🦴 Early Vertebrates & Transitional Forms", expanded=False):
677
+ col_a, col_b = st.columns(2)
678
+
679
+ with col_a:
680
+ _clickable_taxa_section("Early Tetrapods & Amphibians", [
681
+ ("Tiktaalik", ""),
682
+ ("Ichthyostega", ""),
683
+ ("Acanthostega", ""),
684
+ ("Temnospondyli", ""),
685
+ ("Lepospondyli", ""),
686
+ ("Lissamphibia", "modern amphibians"),
687
+ ("Anura", "frogs"),
688
+ ("Caudata", "salamanders"),
689
+ ], num_cols=1)
690
+
691
+ with col_b:
692
+ _clickable_taxa_section("Reptiles & Birds", [
693
+ ("Testudines", "turtles"),
694
+ ("Squamata", "lizards & snakes"),
695
+ ("Aves", "birds"),
696
+ ("Archaeopteryx", ""),
697
+ ("Enantiornithes", ""),
698
+ ("Ichthyornis", ""),
699
+ ("Sphenisciformes", "penguins"),
700
+ ("Phorusrhacidae", "terror birds"),
701
+ ("Dromornithidae", ""),
702
+ ], num_cols=1)
703
+
704
+ with st.expander("🌿 Plants", expanded=False):
705
+ col_a, col_b, col_c = st.columns(3)
706
+
707
+ with col_a:
708
+ _clickable_taxa_section("Early Plants", [
709
+ ("Lycopodiophyta", "club mosses"),
710
+ ("Sphenopsida", "horsetails"),
711
+ ("Pteridophyta", "ferns"),
712
+ ("Progymnospermopsida", ""),
713
+ ("Ginkgophyta", ""),
714
+ ("Ginkgo", ""),
715
+ ], num_cols=1)
716
+
717
+ with col_b:
718
+ _clickable_taxa_section("Seed Plants & Conifers", [
719
+ ("Pinophyta", "conifers"),
720
+ ("Pinaceae", ""),
721
+ ("Cycadophyta", "cycads"),
722
+ ("Bennettitales", ""),
723
+ ("Cordaitales", ""),
724
+ ("Glossopteridaceae", ""),
725
+ ("Corystospermaceae", ""),
726
+ ], num_cols=1)
727
+
728
+ with col_c:
729
+ _clickable_taxa_section("Flowering Plants", [
730
+ ("Angiospermae", "flowering plants"),
731
+ ("Magnoliopsida", ""),
732
+ ("Arecaceae", "palms"),
733
+ ("Poaceae", "grasses"),
734
+ ("Nymphaeaceae", "water lilies"),
735
+ ("Proteaceae", ""),
736
+ ], num_cols=1)
737
+
738
+ st.markdown("---")
739
+ st.caption(
740
+ "Tip: Click any button above to auto-fill the search bar, then click **Search PBDB**. "
741
+ "Higher-level clades (e.g. *Theropoda*, *Ammonoidea*, *Trilobita*) yield large datasets; "
742
+ "individual genera (e.g. *Triceratops*, *Megalodon*, *Archaeopteryx*) give focused results."
743
+ )
data_processor.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PaleoData Explorer — Data Processor
3
+ ====================================
4
+ Takes the raw JSON list returned by :mod:`api_client` and transforms it
5
+ into a clean, analysis-ready ``pandas.DataFrame``.
6
+
7
+ Key responsibilities
8
+ --------------------
9
+ 1. Parse PBDB JSON records into a flat DataFrame.
10
+ 2. Filter out records that lack temporal bounds (``max_ma`` / ``min_ma``)
11
+ or paleocoordinates (``paleolat`` / ``paleolng``).
12
+ 3. Derive a ``middle_age`` column (``(max_ma + min_ma) / 2``) for point
13
+ plotting on temporal charts.
14
+ 4. Optionally filter by a user-supplied geological time window.
15
+ 5. Surface statistics (record counts at each stage) so the UI can show
16
+ the user what was discarded and why.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ import pandas as pd
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Constants
30
+ # ---------------------------------------------------------------------------
31
+
32
+ # PBDB raw field → canonical field name mapping.
33
+ # The PBDB API returns abbreviated keys; we normalise them here so the
34
+ # rest of the pipeline works with descriptive names.
35
+ PBDB_COLUMN_MAP: Dict[str, str] = {
36
+ "eag": "max_ma", # early age → older bound
37
+ "lag": "min_ma", # late age → younger bound
38
+ "tna": "matched_name", # taxon name
39
+ "oei": "early_interval",
40
+ "oli": "late_interval",
41
+ "pla": "paleolat",
42
+ "pln": "paleolng",
43
+ "phl": "phylum",
44
+ "cll": "class",
45
+ "odl": "order",
46
+ "fml": "family",
47
+ "gnl": "genus",
48
+ }
49
+
50
+ # Canonical field names used after renaming.
51
+ FIELDS_TEMPORAL = ("max_ma", "min_ma")
52
+ FIELDS_SPATIAL = ("paleolat", "paleolng")
53
+ FIELDS_TAXONOMIC = (
54
+ "matched_name",
55
+ "early_interval",
56
+ "late_interval",
57
+ "phylum",
58
+ "class",
59
+ "order",
60
+ "family",
61
+ "genus",
62
+ )
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Public API
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def records_to_dataframe(records: List[Dict[str, Any]]) -> pd.DataFrame:
70
+ """Convert raw PBDB occurrence records into a :class:`pandas.DataFrame`.
71
+
72
+ Parameters
73
+ ----------
74
+ records : list[dict]
75
+ The list returned by :func:`api_client.fetch_occurrences`.
76
+
77
+ Returns
78
+ -------
79
+ pd.DataFrame
80
+ A DataFrame where each row is one occurrence. The columns
81
+ include all keys present in the raw JSON, subject to Pandas'
82
+ internal flattening of nested structures when possible.
83
+ Returns an empty DataFrame (0 rows) if the input list is empty.
84
+ """
85
+ if not records:
86
+ logger.warning("records_to_dataframe called with empty list")
87
+ return pd.DataFrame()
88
+
89
+ df = pd.DataFrame(records)
90
+ logger.debug("Raw DataFrame shape: %s", df.shape)
91
+ return df
92
+
93
+
94
+ def clean_occurrence_data(
95
+ df: pd.DataFrame,
96
+ *,
97
+ min_ma: Optional[float] = None,
98
+ max_ma: Optional[float] = None,
99
+ ) -> Tuple[pd.DataFrame, Dict[str, int]]:
100
+ """Clean a raw occurrence DataFrame and optionally subset it to a time window.
101
+
102
+ Cleaning steps (in order)
103
+ -------------------------
104
+ 1. Remove rows where **both** ``max_ma`` and ``min_ma`` are missing
105
+ (records without any age information are unusable).
106
+ 2. Impute missing ``max_ma`` / ``min_ma`` where only one is
107
+ available by taking the available value (conservative estimate).
108
+ 3. Remove rows where ``paleolat`` **or** ``paleolng`` is missing
109
+ (cannot plot on a map).
110
+ 4. Derive ``middle_age`` = ``(max_ma + min_ma) / 2``.
111
+ 5. If ``min_ma`` and/or ``max_ma`` are supplied, filter the
112
+ DataFrame to occurrences whose ``middle_age`` falls within the
113
+ requested window.
114
+
115
+ Parameters
116
+ ----------
117
+ df : pd.DataFrame
118
+ Raw DataFrame from :func:`records_to_dataframe`.
119
+ min_ma, max_ma : float or None
120
+ Optional temporal boundaries in Ma. Only occurrences whose
121
+ ``middle_age`` lies **between** *max_ma* (older bound) and
122
+ *min_ma* (younger bound) are kept.
123
+
124
+ Returns
125
+ -------
126
+ df : pd.DataFrame
127
+ Cleaned, filtered DataFrame. May be empty.
128
+ stats : dict
129
+ Counts at each pipeline stage for transparency:
130
+ ``{"raw": n, "has_temporal": n, "has_spatial": n, "in_window": n}``
131
+ """
132
+ n_raw = len(df)
133
+ stats: Dict[str, int] = {"raw": n_raw}
134
+
135
+ if df.empty:
136
+ logger.warning("Input DataFrame is empty; nothing to clean.")
137
+ stats.update(has_temporal=0, has_spatial=0, in_window=0)
138
+ return df, stats
139
+
140
+ # ---- Step 0: normalise PBDB abbreviated column names ---------------
141
+ rename_map = {k: v for k, v in PBDB_COLUMN_MAP.items() if k in df.columns}
142
+ df = df.rename(columns=rename_map)
143
+ logger.debug("Renamed %d columns: %s", len(rename_map), list(rename_map.keys()))
144
+
145
+ # ---- Step 1: require at least one temporal field -------------------
146
+ temporal_mask = df[list(FIELDS_TEMPORAL)].notna().any(axis=1)
147
+ df = df.loc[temporal_mask].copy()
148
+ stats["has_temporal"] = len(df)
149
+ logger.debug("After temporal filter: %d rows (dropped %d)",
150
+ stats["has_temporal"], n_raw - stats["has_temporal"])
151
+
152
+ # ---- Step 2: impute lone temporal values ---------------------------
153
+ # If max_ma is NaN but min_ma exists, set max_ma = min_ma (point date)
154
+ max_null = df["max_ma"].isna()
155
+ if max_null.any():
156
+ df.loc[max_null, "max_ma"] = df.loc[max_null, "min_ma"]
157
+
158
+ # If min_ma is NaN but max_ma exists, set min_ma = max_ma
159
+ min_null = df["min_ma"].isna()
160
+ if min_null.any():
161
+ df.loc[min_null, "min_ma"] = df.loc[min_null, "max_ma"]
162
+
163
+ # ---- Step 3: require both paleocoordinates -------------------------
164
+ spatial_mask = df[list(FIELDS_SPATIAL)].notna().all(axis=1)
165
+ df = df.loc[spatial_mask].copy()
166
+ stats["has_spatial"] = len(df)
167
+ logger.debug("After spatial filter: %d rows (dropped %d)",
168
+ stats["has_spatial"], stats["has_temporal"] - stats["has_spatial"])
169
+
170
+ # ---- Step 4: compute middle_age ------------------------------------
171
+ df["middle_age"] = (df["max_ma"].astype(float) + df["min_ma"].astype(float)) / 2.0
172
+
173
+ # ---- Step 5: optional time-window filter ---------------------------
174
+ if max_ma is not None or min_ma is not None:
175
+ in_window = pd.Series(True, index=df.index)
176
+
177
+ if max_ma is not None:
178
+ in_window &= df["middle_age"] <= float(max_ma)
179
+ if min_ma is not None:
180
+ in_window &= df["middle_age"] >= float(min_ma)
181
+
182
+ df = df.loc[in_window].copy()
183
+ stats["in_window"] = len(df)
184
+ logger.debug("After time-window filter: %d rows (dropped %d)",
185
+ stats["in_window"], stats["has_spatial"] - stats["in_window"])
186
+ else:
187
+ stats["in_window"] = stats["has_spatial"]
188
+
189
+ # ---- Ensure consistent float types for critical columns ------------
190
+ for col in ("max_ma", "min_ma", "middle_age", "paleolat", "paleolng"):
191
+ if col in df.columns:
192
+ df[col] = pd.to_numeric(df[col], errors="coerce")
193
+
194
+ logger.info("Cleaning pipeline done: raw=%d → has_temporal=%d → has_spatial=%d → in_window=%d",
195
+ stats["raw"], stats["has_temporal"], stats["has_spatial"], stats["in_window"])
196
+ return df, stats
197
+
198
+
199
+ def get_dataframe_statistics(df: pd.DataFrame) -> Dict[str, Any]:
200
+ """Return a small summary dict for display in the UI.
201
+
202
+ Includes unique taxa counts, time span, and geographic extent.
203
+ """
204
+ if df.empty:
205
+ return {"unique_taxa": 0, "time_span_ma": None}
206
+
207
+ stats = {
208
+ "record_count": len(df),
209
+ "unique_taxa": int(df["matched_name"].nunique()),
210
+ "unique_families": int(df["family"].nunique()) if "family" in df.columns else 0,
211
+ "unique_genera": int(df["genus"].nunique()) if "genus" in df.columns else 0,
212
+ "oldest_ma": float(df["middle_age"].max()),
213
+ "youngest_ma": float(df["middle_age"].min()),
214
+ "time_span_ma": float(df["middle_age"].max() - df["middle_age"].min()),
215
+ "lat_extent": float(df["paleolat"].max() - df["paleolat"].min()) if "paleolat" in df.columns else None,
216
+ "lng_extent": float(df["paleolng"].max() - df["paleolng"].min()) if "paleolng" in df.columns else None,
217
+ }
218
+ return stats
docker-compose.yml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ services:
2
+ paleopedia:
3
+ build: .
4
+ ports:
5
+ - "8501:7860"
6
+ restart: unless-stopped
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ streamlit>=1.28.0
2
+ pandas>=2.0.0
3
+ numpy>=1.24.0
4
+ requests>=2.28.0
5
+ plotly>=5.15.0
6
+ folium>=0.15.0
7
+ streamlit-folium>=0.15.0
visualizations.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PaleoData Explorer — Visualizations
3
+ ====================================
4
+ Produces two core scientific visuals from a cleaned occurrence DataFrame:
5
+
6
+ 1. **Paleogeographic Map** (Folium + streamlit-folium)
7
+ Plots fossil localities using paleocoordinates (``paleolat`` /
8
+ ``paleolng``), i.e. where the organism lived accounting for
9
+ tectonic drift.
10
+
11
+ 2. **Deep-Time Timeline** (Plotly)
12
+ A horizontal range chart showing the stratigraphic lifespan of
13
+ each taxon. The X‑axis (geological time in Ma) is **reversed**
14
+ so that older dates appear on the left and younger dates on the
15
+ right, as is standard in geology.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Dict, List, Optional
22
+
23
+ import folium
24
+ from folium.plugins import MarkerCluster
25
+ import numpy as np
26
+ import pandas as pd
27
+ import plotly.express as px
28
+ import plotly.graph_objects as go
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Constants
34
+ # ---------------------------------------------------------------------------
35
+
36
+ DEFAULT_MAP_ZOOM = 3
37
+ MAX_MARKERS_MAP = 1500 # cap markers for performance
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # 1. Paleogeographic Map (Folium)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def build_paleo_map(
45
+ df: pd.DataFrame,
46
+ *,
47
+ height: int = 550,
48
+ zoom: int = DEFAULT_MAP_ZOOM,
49
+ ) -> folium.Map:
50
+ """Create a Folium map of fossil paleocoordinates.
51
+
52
+ Parameters
53
+ ----------
54
+ df : pd.DataFrame
55
+ Cleaned occurrence DataFrame. Must contain at least
56
+ ``paleolat``, ``paleolng``, ``matched_name``.
57
+ height : int
58
+ Height in pixels passed to ``st_folium``.
59
+ zoom : int
60
+ Initial zoom level.
61
+
62
+ Returns
63
+ -------
64
+ folium.Map
65
+ The ready-to-render Folium map object.
66
+ """
67
+ if df.empty:
68
+ logger.warning("Empty DataFrame passed to build_paleo_map")
69
+ return folium.Map(location=[0, 0], zoom_start=zoom, tiles="OpenStreetMap")
70
+
71
+ # Centre on the median paleocoordinate
72
+ center_lat = float(df["paleolat"].median())
73
+ center_lng = float(df["paleolng"].median())
74
+
75
+ m = folium.Map(
76
+ location=[center_lat, center_lng],
77
+ zoom_start=zoom,
78
+ tiles="CartoDB positron",
79
+ control_scale=True,
80
+ )
81
+
82
+ # Sample if too many points (keeps the map snappy)
83
+ work_df = df
84
+ if len(work_df) > MAX_MARKERS_MAP:
85
+ logger.info("Sampling %d points down to %d for map rendering",
86
+ len(work_df), MAX_MARKERS_MAP)
87
+ work_df = work_df.sample(n=MAX_MARKERS_MAP, random_state=42)
88
+
89
+ cluster = MarkerCluster(name="Fossil Occurrences").add_to(m)
90
+
91
+ for _, row in work_df.iterrows():
92
+ lat = float(row["paleolat"])
93
+ lng = float(row["paleolng"])
94
+ if not (np.isfinite(lat) and np.isfinite(lng)):
95
+ continue
96
+
97
+ name = row.get("matched_name", "Unknown")
98
+ early = row.get("early_interval", "")
99
+ max_ma_val = row.get("max_ma", np.nan)
100
+ min_ma_val = row.get("min_ma", np.nan)
101
+
102
+ tooltip_lines = [f"<b>{name}</b>"]
103
+ if early and isinstance(early, str):
104
+ tooltip_lines.append(f"Interval: {early}")
105
+ if np.isfinite(max_ma_val) and np.isfinite(min_ma_val):
106
+ tooltip_lines.append(f"Age: {max_ma_val:.1f}–{min_ma_val:.1f} Ma")
107
+
108
+ folium.CircleMarker(
109
+ location=[lat, lng],
110
+ radius=5,
111
+ color="#c0392b",
112
+ fill=True,
113
+ fill_color="#e74c3c",
114
+ fill_opacity=0.7,
115
+ tooltip=folium.Tooltip("<br>".join(tooltip_lines)),
116
+ popup=folium.Popup(name, parse_html=False, max_width=200),
117
+ ).add_to(cluster)
118
+
119
+ folium.LayerControl().add_to(m)
120
+ return m
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # 2. Deep-Time Timeline (Plotly)
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def build_timeline(
128
+ df: pd.DataFrame,
129
+ *,
130
+ max_taxa: int = 50,
131
+ height: int = 700,
132
+ ) -> go.Figure:
133
+ """Build a Plotly horizontal range chart of taxon stratigraphic ranges.
134
+
135
+ Geological convention: the X‑axis (Time, Ma) is **reversed** so
136
+ that older dates sit on the left.
137
+
138
+ Parameters
139
+ ----------
140
+ df : pd.DataFrame
141
+ Cleaned occurrence DataFrame (requires ``matched_name``,
142
+ ``max_ma``, ``min_ma``, ``middle_age``).
143
+ max_taxa : int
144
+ Maximum number of unique taxa to display (top‑N by oldest age).
145
+ height : int
146
+ Figure height in pixels.
147
+
148
+ Returns
149
+ -------
150
+ go.Figure
151
+ Plotly figure ready for ``st.plotly_chart``.
152
+ """
153
+ if df.empty:
154
+ logger.warning("Empty DataFrame passed to build_timeline")
155
+ fig = go.Figure()
156
+ fig.update_layout(
157
+ title="No data to display",
158
+ xaxis_title="Time (Ma)",
159
+ yaxis_title="Taxon",
160
+ height=300,
161
+ )
162
+ fig.update_xaxes(autorange="reversed")
163
+ return fig
164
+
165
+ # Aggregate to unique taxa: take the overall max_ma / min_ma per name
166
+ agg: pd.DataFrame = (
167
+ df.groupby("matched_name", as_index=False)
168
+ .agg(max_ma=("max_ma", "max"), min_ma=("min_ma", "min"))
169
+ .dropna(subset=["max_ma", "min_ma"])
170
+ )
171
+
172
+ if agg.empty:
173
+ logger.warning("No taxa with valid temporal data remain after aggregation")
174
+ fig = go.Figure()
175
+ fig.update_layout(title="No taxa with valid age data", height=300)
176
+ fig.update_xaxes(autorange="reversed")
177
+ return fig
178
+
179
+ agg["middle_age"] = (agg["max_ma"] + agg["min_ma"]) / 2.0
180
+
181
+ # Keep top-N by oldest age (largest max_ma)
182
+ agg = agg.nlargest(max_taxa, "max_ma")
183
+ agg = agg.sort_values("middle_age", ascending=False)
184
+
185
+ fig = go.Figure()
186
+
187
+ # Draw horizontal line segments for each taxon
188
+ for _, row in agg.iterrows():
189
+ fig.add_trace(
190
+ go.Scatter(
191
+ x=[row["max_ma"], row["min_ma"]],
192
+ y=[row["matched_name"], row["matched_name"]],
193
+ mode="lines+markers",
194
+ line={"color": "#2c3e50", "width": 6},
195
+ marker={"size": 6, "color": "#e74c3c"},
196
+ name=row["matched_name"],
197
+ hovertemplate=(
198
+ f"<b>{row['matched_name']}</b><br>"
199
+ f"Range: {row['max_ma']:.2f}–{row['min_ma']:.2f} Ma<br>"
200
+ f"<extra></extra>"
201
+ ),
202
+ showlegend=False,
203
+ )
204
+ )
205
+
206
+ fig.update_layout(
207
+ title="Fossil Taxon Stratigraphic Ranges",
208
+ xaxis_title="Time (Ma)",
209
+ yaxis_title="",
210
+ height=max(height, 100 + 25 * len(agg)),
211
+ margin={"l": 10, "r": 20, "t": 40, "b": 40},
212
+ hovermode="closest",
213
+ )
214
+
215
+ # Reverse X-axis (older → younger = left → right)
216
+ fig.update_xaxes(autorange="reversed")
217
+
218
+ return fig