Skip to content

Commit 70aa9c3

Browse files
authored
Merge pull request #16 from Climate-REF/consolidate-diagnostic-content
2 parents 570e20c + aa0c02c commit 70aa9c3

88 files changed

Lines changed: 7877 additions & 3048 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.envrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dotenv_if_exists .env

README.md

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
This repository contains the API and Frontend for the Climate Rapid Evaluation Framework (REF). This system enables comprehensive benchmarking and evaluation of Earth system models against observational data, integrating with the `climate-ref` core library.
44

55
This is a full-stack application that consists of a:
6-
* **Backend**: FastAPI API (Python 3.11+)
7-
* FastAPI, Pydantic, SQLAlchemy, OpenAPI documentation
8-
* **Frontend**: React frontend (React 19, TypeScript)
9-
* Vite, Tanstack Router, Tanstack Query, Tailwind CSS, Shadcn/ui, Recharts
6+
7+
* **Backend**: FastAPI API (Python 3.11+)
8+
* FastAPI, Pydantic, SQLAlchemy, OpenAPI documentation
9+
* **Frontend**: React frontend (React 19, TypeScript)
10+
* Vite, Tanstack Router, Tanstack Query, Tailwind CSS, Shadcn/ui, Recharts
1011

1112
**Status**: Alpha
1213

@@ -15,29 +16,41 @@ This is a full-stack application that consists of a:
1516
[![Last Commit](https://img.shields.io/github/last-commit/Climate-REF/ref-app.svg)](https://github.com/Climate-REF/climate-ref/commits/main)
1617
[![Contributors](https://img.shields.io/github/contributors/Climate-REF/ref-app.svg)](https://github.com/Climate-REF/ref-app/graphs/contributors)
1718

18-
1919
## Overview
2020

2121
The Climate REF Web Application provides researchers and scientists with tools to:
22-
- Enable rapid model evaluation and near real-time assessment of climate model performance.
23-
- Provide standardized, reproducible evaluation metrics across different models and datasets.
24-
- Make complex climate model diagnostics accessible through an intuitive web interface.
25-
- Ensure evaluation processes are transparent and results are traceable.
26-
- Consolidate various diagnostic tools into a unified framework.
27-
- Automate the execution of diagnostics when new datasets are available.
28-
- Help researchers find and understand available datasets and their evaluation status.
29-
- Enable easy comparison of model performance across different versions and experiments.
22+
23+
* Enable rapid model evaluation and near real-time assessment of climate model performance.
24+
* Provide standardized, reproducible evaluation metrics across different models and datasets.
25+
* Make complex climate model diagnostics accessible through an intuitive web interface.
26+
* Ensure evaluation processes are transparent and results are traceable.
27+
* Consolidate various diagnostic tools into a unified framework.
28+
* Automate the execution of diagnostics when new datasets are available.
29+
* Help researchers find and understand available datasets and their evaluation status.
30+
* Enable easy comparison of model performance across different versions and experiments.
31+
32+
## Updating Diagnostic Content
33+
34+
Display metadata for each AFT diagnostic collection (descriptions, explanations, plain-language summaries)
35+
is maintained in YAML files under [`backend/static/collections/`](backend/static/collections/).
36+
See the [collections README](backend/static/collections/README.md) for the full schema and instructions.
37+
38+
Diagnostic-level metadata overrides (display names, reference datasets, tags) are split into per-provider
39+
YAML files under `backend/static/diagnostics/` (e.g. `pmp.yaml`, `esmvaltool.yaml`, `ilamb.yaml`),
40+
which can be regenerated from the provider registry with `make generate-metadata`.
41+
42+
After changing content fields or adding new collections, regenerate the frontend TypeScript client with `make generate-client`.
3043

3144
## Getting Started
3245

3346
### Prerequisites
3447

35-
- Python 3.11+ (with `uv` for package management)
36-
- Node.js v20 and npm (for frontend)
37-
- Database: SQLite (development/test) or PostgreSQL (production)
38-
- Docker and Docker Compose (optional, for containerized deployment)
48+
* Python 3.11+ (with `uv` for package management)
49+
* Node.js v20 and npm (for frontend)
50+
* Database: SQLite (development/test) or PostgreSQL (production)
51+
* Docker and Docker Compose (optional, for containerized deployment)
3952

40-
1. **Clone the repository**
53+
1. **Clone the repository**
4154

4255
```bash
4356
git clone https://github.com/Climate-REF/ref-app.git
@@ -46,7 +59,7 @@ The Climate REF Web Application provides researchers and scientists with tools t
4659

4760
### Backend Setup
4861

49-
2. **Set up environment variables**
62+
1. **Set up environment variables**
5063

5164
Create a `.env` file in the project root by copying the `.env.example` file.
5265

@@ -56,35 +69,35 @@ The Climate REF Web Application provides researchers and scientists with tools t
5669

5770
Modify the `.env` to your needs. The `REF_CONFIGURATION` variable should point to the configuration directory for the REF, which defines the database connection string and other REF-specific settings.
5871

59-
3. **Install dependencies**
72+
2. **Install dependencies**
6073

6174
```bash
6275
cd backend
6376
make virtual-environment
6477
```
6578

66-
4. **Start the backend server**
79+
3. **Start the backend server**
6780

6881
```bash
6982
make dev
7083
```
7184

7285
### Frontend Setup
7386

74-
1. **Generate Client**
87+
1. **Generate Client**
7588

7689
```bash
7790
make generate-client
7891
```
7992

80-
2. **Install dependencies**
93+
2. **Install dependencies**
8194

8295
```bash
8396
cd frontend
8497
npm install
8598
```
8699

87-
3. **Start the frontend server**
100+
3. **Start the frontend server**
88101

89102
```bash
90103
npm run dev
@@ -104,6 +117,9 @@ ref-app/
104117
│ │ │ └── main.py # API router aggregation
105118
│ │ ├── core/ # Core application logic (config, file handling, REF initialization)
106119
│ │ └── models.py # Pydantic models for API responses
120+
│ ├── static/
121+
│ │ ├── collections/ # Per-collection YAML metadata (see collections/README.md)
122+
│ │ └── diagnostics/ # Diagnostic metadata overrides
107123
│ ├── tests/ # Backend test suite
108124
│ ├── pyproject.toml # Python dependencies and project metadata
109125
│ └── uv.lock # uv lock file for reproducible dependencies
@@ -125,6 +141,7 @@ ref-app/
125141
## API Documentation
126142
127143
When the backend is running, API documentation is available at:
128-
- Swagger UI: http://localhost:8001/docs
129-
- ReDoc: http://localhost:8001/redoc
130-
- OpenAPI JSON: http://localhost:8001/openapi.json
144+
145+
* Swagger UI: <http://localhost:8001/docs>
146+
* ReDoc: <http://localhost:8001/redoc>
147+
* OpenAPI JSON: <http://localhost:8001/openapi.json>

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ dependencies = [
1111
"psycopg[binary]<4.0.0,>=3.1.13",
1212
"pydantic-settings<3.0.0,>=2.2.1",
1313
"sentry-sdk[fastapi]>=2.0.0",
14-
"climate-ref[aft-providers,postgres]>=0.12.0",
14+
"climate-ref[aft-providers,postgres]>=0.12.2",
1515
"loguru",
1616
"pyyaml>=6.0",
1717
"fastapi-sqlalchemy-monitor>=1.1.3",

backend/scripts/generate_metadata.py

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""
22
Generate diagnostic metadata YAML from the current provider registry.
33
4-
This script bootstraps or updates the metadata.yaml file by iterating all
5-
registered diagnostics and capturing their current state (display_name,
6-
description, tags, reference_datasets). Existing values in metadata.yaml
4+
This script bootstraps or updates the per-provider metadata files by iterating
5+
all registered diagnostics and capturing their current state (display_name,
6+
description, tags, reference_datasets). Existing values in the metadata files
77
take precedence over auto-generated values.
88
99
Usage:
1010
cd backend && uv run python scripts/generate_metadata.py
1111
1212
Options:
13-
--output PATH Write to a specific file (default: static/diagnostics/metadata.yaml)
13+
--output PATH Write to a specific file (default: writes per-provider files
14+
into static/diagnostics/)
1415
--dry-run Print to stdout instead of writing to file
1516
"""
1617

@@ -72,45 +73,35 @@ def _build_entry(
7273

7374

7475
def generate_metadata(output_path: Path | None = None, *, dry_run: bool = False) -> None:
75-
"""Generate metadata.yaml from the provider registry, merging with existing values."""
76+
"""Generate per-provider metadata YAML files from the provider registry, merging with existing values."""
7677
settings = Settings()
7778
ref_config = get_ref_config(settings)
7879
database = get_database(ref_config)
7980
provider_registry = get_provider_registry(ref_config)
8081

81-
# Load existing metadata (existing values take precedence)
82-
default_metadata_path = backend_dir / "static" / "diagnostics" / "metadata.yaml"
83-
metadata_path = output_path or default_metadata_path
84-
existing_metadata = load_diagnostic_metadata(metadata_path)
82+
# Load existing metadata from the directory (existing values take precedence)
83+
default_metadata_dir = backend_dir / "static" / "diagnostics"
84+
metadata_dir = output_path or default_metadata_dir
85+
existing_metadata = load_diagnostic_metadata(metadata_dir)
8586

86-
# Iterate all registered diagnostics
87-
generated: dict[str, dict[str, Any]] = {}
87+
# Group diagnostics by provider
88+
by_provider: dict[str, dict[str, dict[str, Any]]] = {}
8889

8990
with database.session.connection():
9091
for provider_slug, diagnostics in provider_registry.metrics.items():
9192
for diagnostic_slug, concrete_diagnostic in diagnostics.items():
9293
key = f"{provider_slug}/{diagnostic_slug}"
93-
generated[key] = _build_entry(key, diagnostic_slug, concrete_diagnostic, existing_metadata)
94+
entry = _build_entry(key, diagnostic_slug, concrete_diagnostic, existing_metadata)
95+
by_provider.setdefault(provider_slug, {})[key] = entry
9496

9597
# Also include any entries from existing metadata that weren't found in the registry
9698
for key, metadata in existing_metadata.items():
97-
if key not in generated:
98-
generated[key] = _metadata_to_dict(metadata)
99-
100-
# Sort by key for consistent output
101-
sorted_metadata = dict(sorted(generated.items()))
102-
103-
# Generate YAML output
104-
yaml_content = yaml.dump(
105-
sorted_metadata,
106-
default_flow_style=False,
107-
sort_keys=False,
108-
allow_unicode=True,
109-
width=120,
110-
)
99+
provider_slug = key.split("/")[0]
100+
if key not in by_provider.get(provider_slug, {}):
101+
by_provider.setdefault(provider_slug, {})[key] = _metadata_to_dict(metadata)
111102

112103
header = (
113-
"# Diagnostic Metadata\n"
104+
"# {provider} Diagnostic Metadata\n"
114105
"#\n"
115106
"# Auto-generated by: cd backend && uv run python scripts/generate_metadata.py\n"
116107
"#\n"
@@ -120,15 +111,32 @@ def generate_metadata(output_path: Path | None = None, *, dry_run: bool = False)
120111
"#\n\n"
121112
)
122113

123-
output = header + yaml_content
114+
total = 0
115+
for provider_slug, entries in sorted(by_provider.items()):
116+
sorted_entries = dict(sorted(entries.items()))
117+
total += len(sorted_entries)
118+
119+
yaml_content = yaml.dump(
120+
sorted_entries,
121+
default_flow_style=False,
122+
sort_keys=False,
123+
allow_unicode=True,
124+
width=120,
125+
)
126+
127+
output = header.format(provider=provider_slug) + yaml_content
128+
129+
if dry_run:
130+
print(f"--- {provider_slug}.yaml ---")
131+
print(output)
132+
else:
133+
metadata_dir.mkdir(parents=True, exist_ok=True)
134+
file_path = metadata_dir / f"{provider_slug}.yaml"
135+
file_path.write_text(output)
136+
print(f"Generated metadata written to {file_path} ({len(sorted_entries)} diagnostics)")
124137

125-
if dry_run:
126-
print(output)
127-
else:
128-
metadata_path.parent.mkdir(parents=True, exist_ok=True)
129-
metadata_path.write_text(output)
130-
print(f"Generated metadata written to {metadata_path}")
131-
print(f"Total diagnostics: {len(sorted_metadata)}")
138+
if not dry_run:
139+
print(f"Total diagnostics across all providers: {total}")
132140

133141

134142
def main() -> None:
@@ -137,7 +145,7 @@ def main() -> None:
137145
"--output",
138146
type=Path,
139147
default=None,
140-
help="Output file path (default: static/diagnostics/metadata.yaml)",
148+
help="Output directory (default: static/diagnostics/)",
141149
)
142150
parser.add_argument(
143151
"--dry-run",
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from fastapi import APIRouter
22

3-
from ref_backend.api.routes import aft, datasets, diagnostics, executions, results, utils
3+
from ref_backend.api.routes import aft, datasets, diagnostics, executions, explorer, results, utils
44

55
api_router = APIRouter()
66
api_router.include_router(aft.router)
77
api_router.include_router(datasets.router)
88
api_router.include_router(diagnostics.router)
99
api_router.include_router(executions.router)
10+
api_router.include_router(explorer.router)
1011
api_router.include_router(results.router)
1112
api_router.include_router(utils.router)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from fastapi import APIRouter, HTTPException
2+
3+
from ref_backend.core.collections import (
4+
AFTCollectionDetail,
5+
AFTCollectionSummary,
6+
ThemeDetail,
7+
ThemeSummary,
8+
get_collection_by_id,
9+
get_collection_summaries,
10+
get_theme_by_slug,
11+
get_theme_summaries,
12+
)
13+
14+
router = APIRouter(prefix="/explorer", tags=["Explorer"])
15+
16+
17+
@router.get("/collections/", response_model=list[AFTCollectionSummary])
18+
async def list_collections() -> list[AFTCollectionSummary]:
19+
return get_collection_summaries()
20+
21+
22+
@router.get("/collections/{collection_id}", response_model=AFTCollectionDetail)
23+
async def get_collection(collection_id: str) -> AFTCollectionDetail:
24+
result = get_collection_by_id(collection_id)
25+
if result is None:
26+
raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found")
27+
return result
28+
29+
30+
@router.get("/themes/", response_model=list[ThemeSummary])
31+
async def list_themes() -> list[ThemeSummary]:
32+
return get_theme_summaries()
33+
34+
35+
@router.get("/themes/{theme_slug}", response_model=ThemeDetail)
36+
async def get_theme(theme_slug: str) -> ThemeDetail:
37+
result = get_theme_by_slug(theme_slug)
38+
if result is None:
39+
raise HTTPException(status_code=404, detail=f"Theme '{theme_slug}' not found")
40+
return result

0 commit comments

Comments
 (0)